Migrate-to-seed submits z_mergetoaddress -> an async opid, then only persists the resolved txid once the op completes. An app-close during Sweeping (opid submitted, txid not yet resolved) dropped the opid and resumed at the re-sweep gate, silently losing the tx. Now the opid is persisted and re-tracked on resume. If the daemon forgot it (restart — its op queue is in-memory only), the existing poller flags it stale and the callback falls back to the dismissable Sweep gate; it can never hang (a thrown RPC aborts the poll, so a stale classification only comes from a *successful* poll that omits the opid). - New seed_migration_sweep_opid setting; adopted atomically with clearing any prior txid in the SAME settings.save(), and only once the submit succeeds — so a failed "Sweep remaining" re-sweep keeps the already-mined first sweep's Confirming context, and txid/opid are never both authoritative (resume checks txid first; torn-write safe). - Resume routing extracted to a pure, unit-tested helper (data/seed_migration_resume.h::decideSeedMigrationResume): txid -> Confirming; opid AND connected -> re-track (Sweeping); else -> the dismissable Sweep gate. The connectivity gate keeps a disconnected resume out of the buttonless Sweeping spinner. - Shared makeSweepCompletionCallback(resumed): success -> Confirming; resumed-stale -> Sweep gate (re-fetch balance + "may have already completed" copy); fresh-fail -> Error. Fund safety unchanged: adopt still gated on legacy balance ~0 AND sweep tx mined; legacy wallet.dat only ever moved to a never-deleted timestamped .bak. Reviewed in two adversarial rounds (design + implementation) per the migration-code mandate; both safety facts (no fund loss, no hang) held, and the resume-UX traps they surfaced are fixed. Build-clean; ctest 1/1 (adds testSeedMigrationResume). See docs/wallet-hardening.md. *** Still requires a live mainnet interrupted-sweep run before release (human gate). *** Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
27 KiB
Wallet Loading & Management — Hardening Plan
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
diagnosability QoL). Companion to the findings artifact. Line references are against dev.
- Provenance: 7 parallel subsystem finders, each finding adversarially verified against the code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted (W1-5), 1 raised (W5-3 Low→Med).
- Severity: 8 High · 12 Medium · 13 Low.
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
Roadmap (ordered by risk; shared fixes grouped)
| Phase | Findings | Theme | Status |
|---|---|---|---|
| P0-A | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| P0-B | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| P1-A | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
| P1-B | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| P2 | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
| F | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
P0-A — Secret hardening
Shared fix: a SecureString RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
key/passphrase paths, plus console redaction and deleting the plaintext export.
- W7-1 (High)
console_tab.cpp:1419— RPC console echoes/stores/clipboards raw secrets. Fix: an allowlist of secret-bearing first-tokens (walletpassphrase,walletpassphrasechange,encryptwallet,importprivkey,importwallet,z_importkey,z_importviewingkey,signrawtransaction,magicrecoverkey, lite equivalents); echo> walletpassphrase ****and keep the raw text out ofcommand_history_. Extract a pureredactConsoleCommand(cmd)helper for unit testing. ← implementing first (self-contained + testable). - W2-1 (High)
wallet_security_workflow.cpp:66— delete theobsidiandecryptexport<ts>plaintext key dump afterz_importwalletsucceeds (overwrite-then-unlink). - W4-3 (High)
app_network.cpp:4481—sodium_memzerothe concatenated all-keys string inexportAllKeys; write the backup 0600. (Also unify withExportAllKeysDialog— QoL.) - W4-1 (High)
app_network.cpp:3801— zero the key copies inimportPrivateKey/sweepPrivateKey(local + worker-lambda copies). - W2-3 (Med)
app_security.cpp:1481— zero the passphrase threaded through the decrypt lambda chain. - W4-5 (Med)
app.cpp:3577— the seed-backup.txtis a permanent predictable cleartext seed; at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase. - W5-3 (Med)
lite_wallet_lifecycle_service.cpp:322— remove the deadpassphrasefield from the lite create/open/restore requests (unused; a secret copied for nothing).
P0-B — Encryption integrity
- W2-2 / W4-2 (High)
wallet_security_controller.h:89— the wizard's deferred encryption is in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a wallet the user believes is encrypted stays plaintext. Fix: persist a lightweightencryption_requested_but_incompletesettings flag (NEVER the passphrase) whenbeginDeferredEncryptionis called; surface a persistent warning banner while it's set; clear it only on confirmedencryptwalletsuccess; on next connect, if set, re-prompt for the passphrase to complete it. - W2-4 (Med)
app_security.cpp:480—lockWalletonly setslockedon RPC success; log the failure and notify (currently a silent no-op that can leave the wallet unlocked).
P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
- W3-1 (High)
app_network.cpp:4327— adopt hardcodesdatadir + "/wallet.dat"; usesettings_->getActiveWalletFile()so migrating a non-default active wallet swaps the right file. - W3-2 (High)
seed_wallet_creator.cpp:57—remove_all(<config>/seed-migrate)unconditionally at Phase-1 start; refuse to wipe if a tempDRAGONX/wallet.datalready exists (a prior un-adopted swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry. - W3-4 (Med)
app_network.cpp:1124— block wallet switching while a migration is pending (getSeedMigrationPending()), not only while the dialog is open. - W3-3 (Med)
app_network.cpp:4231— persist the sweep opid so an app-close mid-Sweeping can resume/re-poll it instead of silently dropping the txid.
P1-B — Missing/wrong wallet-file safety
- W1-1 (High)
app_network.cpp:1109—fs::exists()-check the target wallet file inswitchToWallet()and before the first daemon launch at startup; if missing, block with an explicit "Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the daemon fabricate an empty wallet. - W1-3 (Med)
app_network.cpp:1095— defer thesyncedHere=truestamp to the first successful address/balance readback (idHash non-empty), not bareonConnected(). - W1-2 (Med)
app_network.cpp:198— splitDB_CORRUPT-specific strings from the generic "Error loading wallet" fallback; giveDB_TOO_NEWits own message/action (not a salvage offer). - W1-4 (Low)
wallets_dialog.h:393— re-fs::exists()the in-datadir row before switching (match the out-of-datadir path).
P2 — State & lite persistence
- W6-2 (Med)
network_refresh_service.cpp:1183— record a per-field last-success timestamp / a "refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current. - W5-1 / W5-2 (Med)
lite_wallet_controller.cpp:78,603—liteLog()the failed save and bubble a one-shot UI warning (both call sites currently discard the bool). - W6-1 (Med)
wallet_state.h:313— resetmining/pool_mininginclear()(or comment why not). - W6-3 (Low)
address_book.cpp:46— per-entry try/catch: skip + count malformed entries instead of discarding the whole list.
F — Diagnostics foundation + QoL
Land W7-2 first — it unblocks the rest.
- W7-2 (Med)
logger.cpp:31— callLogger::instance().init(<config>/dragonx-debug.log)early inmain()on all platforms; add an "Open log folder" action. - W7-3 (Med)
main.cpp:144— add asigaction-based crash handler writingdragonx-crash.logon POSIX (mirror the Windows SEH path). - W7-4 (Low)
logger.cpp:39— size-cap/rotate the log oninit(). - QoL — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner; refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
Progress log
-
Adversarial review of the 3 diagnostics UI features — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found no Push/Pop imbalance. Fixes landed:
- (Med) Alert popup grew off the right edge — pivot
(0,1)pinned the panel's left edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicitSetNextWindowPospivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-right corner at the bell (pivot(1,1), atbellMax.x) so it grows left over the canvas. - (Low) Staleness badge could flash red on reconnect —
WalletState::clear()reset everything except the fourlast_*_updatestamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps inclear()(all readers treat 0 as "never"; verifiedapp_network.cpp:1473guards on!= 0). - (Low) Banner min-height floor wasn't DPI-scaled —
std::max(minH, baseH*vScale())compared a raw-px floor against a scaled value; nowminH * dpiScale(). - (Low) New i18n keys weren't in
res/lang/— back-filled all 16 diagnostics/QoL keys (this session's node_banner_/data_stale_/alerts_/settings_/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
- (Med) Alert popup grew off the right edge — pivot
-
Foundation QoL / Persistent alert history — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past.
Notificationsnow retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (AlertRecord) — separate from the 5-item live-toast deque — plus a monotonictotal_pushed_counter. A bell in the status-bar right cluster (ICON_MD_NOTIFICATIONS) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (formatTimeAgoShort), with a Clear-all action. An unread dot on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven bytotalPushed()deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-threadMainCbs), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean;ctest1/1 (addstestNotificationHistory: retention, order, cap, monotonic counter, clear). This closes the QoL bundle and the Foundation tier. -
W6-2 / Refresh-staleness badge — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) only when connected but the balance stopped refreshing — a busy daemon can fail
z_gettotalbalancewithout dropping the whole connection (only both core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed:WalletState::last_balance_updateis already stamped only on a successful fetch (network_refresh_service.cpp:1187), so the badge just reads it and computes age against the samestd::timeclock (util::formatTimeAgoShort). Decision is a pure, unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, thresholds 45s/180s) gated onconnectedso it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean;ctest1/1 (addstestStalenessBadge). This closes P2 (5/5). -
Foundation QoL / Persistent node-status banner — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (
ui/node_status_banner.h→evaluateNodeStatusBanner, unit-tested) fed a state snapshot byApp::renderNodeStatusBanner(). Three cases: full-node offline (amber, "Reconnect" →tryConnect), embedded daemon crashed & auto-restart gave up (red, "Restart node" →restartDaemon), lite wallet failed to open (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height inres/themes/ui.toml(banners.node-status); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean;ctest1/1 (addedtestNodeStatusBanner). Remaining QoL: persistent alert history, and the W6-2 refresh-staleness badge. -
Foundation QoL / "Copy diagnostics" + "Open log folder" — ☑ landed: Settings (logging section) now has two actions. Open log folder opens the config dir (
Platform::openFolder) so users can actually finddragonx-debug.log/dragonx-crash.log. Copy diagnostics copies a plaintext support snapshot to the clipboard via the newApp::buildDiagnosticsReport()— version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean;ctest1/1. Remaining QoL: persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge. -
Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure) — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
- W7-2 (Med, keystone): the app-level
Loggerfile sink was never initialized, soLOG/LOGF/VERBOSE_LOGFwent nowhere anddragonx-debug.logdidn't exist on Linux/macOS at all.main()now callsLogger::init(<config>/dragonx-debug.log)on all platforms. Also fixed a latent deadlock this exposed:init()wrote its banner viawrite(), which re-locks the non-recursivemutex_it already holds — now written directly. On Windows the raw stdout/stderrfreopenwas moved to a separatedragonx-stdout.logso the two writers don't contend. NewtestLoggerFileSink(also a deadlock guard — it would hang if that regressed). - W7-3 (Med): no crash handler existed on Linux/macOS. Added an async-signal-safe
sigactionhandler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id +backtrace_symbols_fdbacktrace todragonx-crash.log, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter. - W7-4 (Low):
Logger::initnow rotates the log to a single.1backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded. Build-clean;ctest1/1. Remaining Foundation: the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- W7-2 (Med, keystone): the app-level
-
P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch) — ☑ landed:
- W5-1 (Med):
persistAfterBroadcast(lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It nowliteLogs the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss). - W5-2 (Med): the post-sync and post-rescan
saveresults (in the detached scan threads) were ignored; both nowliteLogon failure (LiteDiagnostics::logis mutex-guarded, safe from those threads). - W6-1 (Med):
WalletState::clear()didn't resetmining/pool_mining, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset inclear()(the daemon restarts on switch, so mining genuinely stops). - W6-3 (Low):
AddressBook::load()didentries_.clear()then threw on the first non-object element — discarding every contact. Now it guardsis_object()+ per-entry try/catch, skipping and counting malformed entries. Build-clean;ctest1/1. Remaining P2: W6-2 (surface refresh staleness — the timestamps exist inWalletState; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
- W5-1 (Med):
-
P1-B / W1-3 + startup wallet-existence guard — ☑ landed:
- W1-3 (Med):
syncedHerewas stamped in themarkOpenedblock at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (updateWalletIndexForActiveWalletafter addresses load), whilelastOpenedEpochstill records at open. - Startup guard (the W1-1 launch counterpart):
App::initnowexists()-checks the recorded active wallet before the daemon is configured; a non-default active wallet that was moved/deleted between sessions falls back to the defaultwallet.datwith a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened. Build-clean;ctest1/1.
- W1-3 (Med):
-
P1-A / W3-3 (sweep opid persistence) — ☑ implemented + two rounds of adversarial review (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was refuted by the code: the opid poller (
app.cpp:1122) +parseOperationStatusPollclassify a tracked opid absent from a successfulz_getoperationstatusas stale, remove it, and fire the callbackok=false— a thrown RPC aborts the poll so there's never a false stale. So re-tracking yields at worst one clean failure, never a hang.- What landed: a persisted
seed_migration_sweep_opidsetting; the opid is adopted atomically with clearing any prior txid in the samesettings.save()only once the submit succeeds (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (data/seed_migration_resume.h::decideSeedMigrationResume): txid → Confirming; opid and connected → re-track (Sweeping); otherwise → the dismissable Sweep gate. The sharedmakeSweepCompletionCallback(resumed): success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error. - Round 1 (design review, 4 skeptics) confirmed both safety facts (no fund loss — adopt gate + never-deleted
.bakuntouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing connectivity gate (would trap the user in the buttonlessSweepingspinner while offline), a missing balance re-fetch on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a successful sweep read "stale". - Round 2 (implementation review, 3 reviewers) caught one regression — clearing the old txid at sweep entry would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
- ⚑ Remaining gate — live mainnet run (user): per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
- What landed: a persisted
-
P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety) — ☑ landed:
- W1-1 (High):
switchToWalletnever checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing-wallet=), looking exactly like fund loss. It nowstd::filesystem::exists-checksdatadir + "/" + walletFilebefore switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter howswitchToWalletis invoked — it also closes W1-4 (the stale-switcher-row TOCTOU). - W1-2 (Med):
walletOutputLooksCorruptmatched the generic "Error loading wallet" string, so aDB_TOO_NEW(newer-version) wallet was offered a-salvagewalletrepair that can't fix it. Now the generic match is excluded when the output also contains "newer version". Build-clean;ctest1/1. Remaining P1-B: W1-3 (defer thesyncedHerestamp to a verified readback) + the startup-path existence check (app.cpphandsgetActiveWalletFile()to the daemon with noexists()check — same silent-empty-wallet risk as W1-1 but at launch).
- W1-1 (High):
-
P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness) — ☑ landed (fund-adjacent — reviewed carefully):
- W3-1 (High):
beginAdoptSeedWallethardcodeddatadir + "/wallet.dat"as the file to swap. With a non-default active wallet (e.g.wallet-2.dat), that installed the swept seed wallet into an unloadedwallet.datand left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swapsdatadir + "/" + getActiveWalletFile()(captured on the main thread; switching is blocked during migration so it can't race). - W3-2 (High):
SeedWalletCreator::createdidremove_all(<config>/seed-migrate)unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) whenDRAGONX/wallet.datalready exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one. - W3-4 (Med):
switchToWalletonly blocked switching while the migration dialog was open; closing it via "Later" mid-migration dropped the guard. Now also blocks whilegetSeedMigrationPending(). Build-clean;ctest1/1. Remaining P1-A: W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
- W3-1 (High):
-
P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail) — ☑ landed:
- W2-2: the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with no record it was ever requested — the user believing it was encrypted. Now a persisted
encryption_pendingsettings flag is set the moment encryption is requested (never the passphrase — only the fact).refreshWalletEncryptionState()reconciles it on every connect: wallet observed encrypted → clear the flag; wallet not encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session "your wallet is NOT encrypted — open Settings to finish" warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice. - W2-4:
lockWallet()'s continuation only handled success — a failedwalletlocksilently left the wallet unlocked (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed. Touchessettings.{h,cpp},app_wizard.cpp,app_security.cpp,app.h. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean,ctest1/1.
- W2-2: the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with no record it was ever requested — the user believing it was encrypted. Now a persisted
-
P0-A / W5-3 (lite create-time passphrase) — ☑ landed (chose option (b) wire it up). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in
LiteWalletController: create/restore →encryptWallet(passphrase)(the backend encrypts + locks + saves the brand-new wallet); open →unlockWallet(passphrase), but only whenencryptionStatus()reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure isliteLog'd (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidentalhunter2create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and addedtestLiteWalletControllerCreateEncryptsWithPassphraseto prove the new behavior. Build-clean;ctest1/1. (Follow-up UX polish:settings_pagecould show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.) -
P0-A / W4-5 (seed-backup file) — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to ". It now reads "Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy: ", so the plaintext-on-disk risk is called out.
i18n.cpp(English source;res/langback-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision. -
P0-A / W4-1 · W4-3 · W2-3 (memzero cluster) — ☑ landed, using the file's established
sodium_memzeropattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:- W4-1
importPrivateKey/sweepPrivateKey: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda mademutable, zeroed after the request is sent), and the JSON requestparamscopy. - W4-3
exportAllKeys/backupWallet: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written viaPlatform::writeFileAtomically(..., restrictPermissions=true)(atomic + 0600) instead of a umask-defaultofstream. - W2-3 decrypt-wallet passphrase:
std::move-captured into the worker lambda (so no plaintext copy is left in the calling frame) andsodium_memzero'd right afterunlockWallet(its only use). Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean;ctest1/1 (no regression). Remaining in P0-A: W5-3 (remove the dead litepassphrasefield), W4-5 (predictable plaintext seed-backup file).
- W4-1
-
P0-A / W2-1 — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (
obsidiandecryptexport…) as soon as thez_importwalletattempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (wallet.dat.encrypted.bak).app_security.cpp(after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean,ctest1/1 (no regression). -
P0-A / W7-1 — ☑ landed:
RedactConsoleCommand/ConsoleCommandCarriesSecretinconsole_tab_helpersredact secret-bearing commands (an allowlist of 13 first-tokens:walletpassphrase,encryptwallet,z_importkey, …) to> walletpassphrase ****before they hit the console echo AND the recall history; the real command still executes unredacted. Wired intosubmitConsoleCommand(console_tab.cpp). NewtestConsoleSecretRedaction(11 assertions). Clean build;ctest1/1. (Output-secret commands likez_exportkey— result redaction — remain a follow-up.)