CRITICAL: Move NWC wallet secret from plaintext nwc_connection.txt to OS keychain. The NWC secret is a private key that can authorize Lightning payments — storing it in plaintext allowed any process to steal funds. Security fixes: - NWC secret stored in OS keychain as "nwc_<npub>" (per-account) - accounts.json.enc is now the sole source of truth for cold boot - Eliminate bunker_uri.txt, last_account.txt, nwc_connection.txt - Legacy files deleted on first startup (one-time cleanup) - logout(deleteKey=true) now removes account from accounts.json.enc - Corrupted accounts.json.enc backed up as .corrupt.<timestamp> Cold boot rewrite: - loadSavedAccount() routes by SignerType from accounts.json.enc - No longer reads stale bunker_uri.txt (fixes nsec→bunker confusion) - No longer reads last_account.txt (uses activeNpub from metadata) Multi-account improvements: - NWC connections are per-account (switch account = switch wallet) - Each account type (Internal/Remote/ViewOnly) loads correctly - saveBunkerAccount() no longer writes to bunker_uri.txt Updated 8 existing test files to use accountStorage instead of writing legacy files directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
18 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| fix: Account Security Hardening | fix | active | 2026-05-14 | docs/brainstorms/2026-05-14-fix-account-security-hardening-brainstorm.md |
Account Security Hardening
Deepening Insights (2026-05-14)
- NWC URI reconstruction:
Nip47URINormhas a direct constructor(pubKeyHex, relayUri, secret, lud16)— no URI string parsing needed. Store pubKeyHex+relayUri in metadata, secret in keychain, reconstruct via constructor. - SecureKeyStorage alias: Accepts any arbitrary string —
"nwc_npub1..."confirmed to work. No format validation on alias. - Corruption detection: Use
StateFlow<StorageCorruption?>onAccountManager(matches existingforceLogoutReasonpattern). Distinguish key-lost (getPrivateKey("account-metadata-key")returns null) vs file-corrupted (AEADBadTagException) vs JSON-malformed (JacksonException). - NWC save failure: Don't fall back to plaintext or in-memory — fail the connection entirely. NWC secrets control real money.
- Cold boot ConnectingRelays: Must read
accounts.json.encfirst to know account type, then show ConnectingRelays UI if Remote. Can't show it before reading. - ensureCurrentAccountInStorage: No longer needed —
loadSavedAccount()only loads fromaccounts.json.enc, so account is guaranteed to be there. - Backup pattern: Use timestamped backups (
accounts.json.enc.corrupt.<millis>) to avoid overwriting previous backups.
Overview
Single PR fixing 6 security and correctness issues in desktop account management.
Makes accounts.json.enc the sole source of truth, moves NWC secrets to OS keychain,
makes NWC per-account, and eliminates stale file bugs. (see brainstorm for full rationale)
Problem Statement
- CRITICAL: NWC wallet secret in plaintext
nwc_connection.txt— funds can be stolen - HIGH: NWC is global — switching accounts doesn't switch wallets
- HIGH: Cold boot reads stale
bunker_uri.txtbeforeaccounts.json.enc— nsec misidentified as bunker - MEDIUM:
logout(deleteKey=true)doesn't remove fromaccounts.json.enc— ghost accounts - MEDIUM:
accounts.json.enccorruption silently returns empty — data loss - LOW:
bunker_uri.txtis singleton — can't support multi-bunker
Implementation Phases
Phase 1: NWC Secret to Keychain + Per-Account (Issues 1, 2)
1a: Add NWC fields to AccountInfoDto
File: DesktopAccountStorage.kt:223-258
internal data class AccountInfoDto(
val npub: String,
val signerKind: String,
val bunkerUri: String? = null,
val displayName: String? = null,
val isTransient: Boolean = false,
val nwcPubKey: String? = null, // NEW: wallet service pubkey (non-secret)
val nwcRelay: String? = null, // NEW: wallet relay URL (non-secret)
)
Jackson handles new nullable fields transparently — old accounts.json.enc files
deserialize without them (null defaults). AccountInfo in commons also needs
matching fields (or AccountInfoDto.toAccountInfo() maps them).
1b: Rewrite NWC methods in AccountManager
File: AccountManager.kt:788-844
Replace setNwcConnection(), clearNwcConnection(), loadNwcConnection().
Key insight from deepening: Nip47URINorm has a direct constructor — no URI
string reconstruction needed. Store components, rebuild via constructor.
// --- NWC (per-account, secret in keychain) ---
private fun nwcKeyAlias(npub: String) = "nwc_$npub"
fun setNwcConnection(npub: String, uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
try {
val parsed = Nip47WalletConnect.parse(uri)
val secret = parsed.secret ?: throw IllegalArgumentException("NWC URI has no secret")
// Secret → keychain (fail entirely if keychain unavailable — don't fall back to plaintext)
secureStorage.savePrivateKey(nwcKeyAlias(npub), secret)
// Non-secret parts → accounts.json.enc
scope.launch {
val info = accountStorage.loadAccounts().find { it.npub == npub }
if (info != null) {
accountStorage.saveAccount(
info.copy(
nwcPubKey = parsed.pubKeyHex,
nwcRelay = parsed.relayUri.toString(),
),
)
}
}
_nwcConnection.value = parsed
Result.success(parsed)
} catch (e: Exception) {
Result.failure(e)
}
fun clearNwcConnection(npub: String) {
try { secureStorage.deletePrivateKey(nwcKeyAlias(npub)) } catch (_: SecureStorageException) {}
scope.launch {
val info = accountStorage.loadAccounts().find { it.npub == npub }
if (info != null) {
accountStorage.saveAccount(info.copy(nwcPubKey = null, nwcRelay = null))
}
}
_nwcConnection.value = null
}
fun loadNwcConnection(npub: String) {
val secret = try {
secureStorage.getPrivateKey(nwcKeyAlias(npub))
} catch (_: SecureStorageException) { null }
if (secret != null) {
scope.launch {
val info = accountStorage.loadAccounts().find { it.npub == npub }
if (info?.nwcPubKey != null && info.nwcRelay != null) {
// Reconstruct directly — no URI parsing needed
_nwcConnection.value = Nip47WalletConnect.Nip47URINorm(
pubKeyHex = info.nwcPubKey,
relayUri = NormalizedRelayUrl(info.nwcRelay),
secret = secret,
)
}
}
} else {
_nwcConnection.value = null
}
}
1c: Delete legacy NWC file helpers
Remove from AccountManager.kt:
saveNwcUri()(line 839-841)getNwcFile()(line 844)
Delete on startup: Add to loadSavedAccount():
// Clean up legacy plaintext NWC file
File(amethystDir, "nwc_connection.txt").delete()
1d: Update callers
switchAccount()(line 761):loadNwcConnection()→loadNwcConnection(targetNpub)Main.kt: anyloadNwcConnection()calls → pass npub- Wallet column:
setNwcConnection(uri)→setNwcConnection(npub, uri)
Phase 2: Cold Boot from accounts.json.enc (Issue 3)
2a: Rewrite loadSavedAccount()
File: AccountManager.kt:233-247
Replace entirely:
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
try {
val activeNpub = accountStorage.currentAccount()
?: return Result.failure(Exception("No saved account"))
val accounts = accountStorage.loadAccounts()
val info = accounts.find { it.npub == activeNpub }
?: return Result.failure(Exception("Account not found in storage"))
// Clean up legacy files (one-time)
File(amethystDir, "last_account.txt").delete()
File(amethystDir, "bunker_uri.txt").delete()
File(amethystDir, "nwc_connection.txt").delete()
when (info.signerType) {
is SignerType.Internal -> loadInternalAccount(activeNpub)
is SignerType.Remote -> loadBunkerAccount(
(info.signerType as SignerType.Remote).bunkerUri,
activeNpub,
)
is SignerType.ViewOnly -> loadReadOnlyAccount(activeNpub)
}
} catch (e: Exception) {
Result.failure(e)
}
2b: Rewrite saveCurrentAccount()
File: AccountManager.kt:460-492
Remove saveLastNpub() call (line 484). The accountStorage.setCurrentAccount()
call is sufficient.
2c: Remove legacy file helpers
Remove from AccountManager.kt:
getLastNpub()(line 846-848)saveLastNpub()(line 851-853)clearLastNpub()(line 856-857)getPrefsFile()(line 860)getBunkerUri()(line 862-864)saveBunkerUri()(line 867-869)getBunkerFile()(line 872)hasBunkerAccount()— usesgetBunkerUri(), no longer needed
2d: Update saveBunkerAccount()
Stop writing to bunker_uri.txt. The bunker URI is already saved in
accounts.json.enc via AccountInfoDto.bunkerUri.
2e: Update Main.kt startup
File: Main.kt:831-853
Deepening insight: ensureCurrentAccountInStorage() is no longer needed
since loadSavedAccount() only loads from accounts.json.enc (account is
guaranteed to be there). ConnectingRelays UI must come AFTER reading
metadata to know the account type.
scope.launch(Dispatchers.IO) {
accountManager.refreshAccountListOnStartup()
val result = accountManager.loadSavedAccount()
if (result.isSuccess) {
accountManager.refreshAccountList()
val current = accountManager.currentAccount()
if (current?.signerType is SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
// Load per-account NWC
accountManager.loadNwcConnection(current!!.npub)
}
// If failure: state remains LoggedOut → login screen shows automatically
}
Removed:
hasBunkerAccount()— was based onbunker_uri.txtexistenceensureCurrentAccountInStorage()— no longer neededlogout(deleteKey=true)fallback for corrupt bunker state — corruption handling in Phase 4 covers this
Phase 3: Logout Cleanup (Issue 4)
3a: Fix logout(deleteKey=true)
File: AccountManager.kt:561-589
suspend fun logout(deleteKey: Boolean = false) {
val current = currentAccount()
if (current != null) {
if (current.signerType is SignerType.Remote) {
(current.signer as? NostrSignerRemote)?.closeSubscription()
}
if (deleteKey) {
try { secureStorage.deletePrivateKey(current.npub) } catch (_: SecureStorageException) {}
try { secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(current.npub)) } catch (_: SecureStorageException) {}
try { secureStorage.deletePrivateKey(nwcKeyAlias(current.npub)) } catch (_: SecureStorageException) {}
// Remove from metadata — fixes ghost accounts
accountStorage.deleteAccount(current.npub)
}
}
disconnectNip46Client()
_nwcConnection.value = null
_signerConnectionState.value = SignerConnectionState.NotRemote
_lastPingTimeSec.value = null
_accountState.value = AccountState.LoggedOut
stopHeartbeat()
}
Key changes:
- Add
secureStorage.deletePrivateKey(nwcKeyAlias(current.npub)) - Add
accountStorage.deleteAccount(current.npub)whendeleteKey=true - Remove
getBunkerFile().delete()andclearLastNpub()(files eliminated)
Phase 4: Corruption Handling (Issue 5)
Deepening insight: Use StateFlow pattern (matches existing forceLogoutReason).
Distinguish key-lost vs file-corrupted. Use timestamped backups.
4a: Add StorageCorruption sealed class to AccountManager
sealed class StorageCorruption {
data class KeyLost(val backupPath: String?) : StorageCorruption()
data class FileCorrupted(val backupPath: String?) : StorageCorruption()
data class JsonMalformed(val backupPath: String?) : StorageCorruption()
}
private val _storageCorruption = MutableStateFlow<StorageCorruption?>(null)
val storageCorruption: StateFlow<StorageCorruption?> = _storageCorruption.asStateFlow()
fun clearStorageCorruption() { _storageCorruption.value = null }
4b: Rewrite readMetadataFromDisk in DesktopAccountStorage
File: DesktopAccountStorage.kt:114-126
Accept a corruption callback (constructor param), catch specific exceptions:
class DesktopAccountStorage(
homeDir: File,
private val secureStorage: SecureKeyStorage,
private val onCorruption: (StorageCorruption) -> Unit = {}, // NEW
) : AccountStorage {
// ...
private suspend fun readMetadataFromDisk(): AccountMetadata {
val file = getAccountsFile()
if (!file.exists()) return AccountMetadata()
val encrypted = file.readBytes()
if (encrypted.size < GCM_IV_SIZE) {
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.FileCorrupted(backup))
return AccountMetadata()
}
return try {
val decrypted = decrypt(encrypted)
mapper.readValue<AccountMetadata>(decrypted)
} catch (e: javax.crypto.AEADBadTagException) {
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.FileCorrupted(backup))
AccountMetadata()
} catch (e: javax.crypto.BadPaddingException) {
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.FileCorrupted(backup))
AccountMetadata()
} catch (e: com.fasterxml.jackson.core.JacksonException) {
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.JsonMalformed(backup))
AccountMetadata()
} catch (e: Exception) {
Log.e("DesktopAccountStorage", "Failed to read accounts metadata", e)
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.FileCorrupted(backup))
AccountMetadata()
}
}
private fun backupCorruptFile(file: File): String? =
try {
val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}")
Files.copy(file.toPath(), backup.toPath())
file.delete()
backup.absolutePath
} catch (_: Exception) { null }
}
4c: Wire in AccountManager
Pass corruption callback when constructing DesktopAccountStorage:
val accountStorage = DesktopAccountStorage(
homeDir = homeDir,
secureStorage = secureStorage,
onCorruption = { _storageCorruption.value = it },
)
4d: Show warning dialog in Main.kt
Observe storageCorruption StateFlow (same pattern as forceLogoutReason):
val corruption by accountManager.storageCorruption.collectAsState()
corruption?.let { c ->
AlertDialog(
onDismissRequest = { accountManager.clearStorageCorruption() },
title = { Text("Account Data Issue") },
text = {
Text(when (c) {
is StorageCorruption.KeyLost -> "Encryption key not found. Account list cannot be recovered."
is StorageCorruption.FileCorrupted -> "Account data was corrupted."
is StorageCorruption.JsonMalformed -> "Account data was partially written."
} + if (c.backupPath != null) "\n\nA backup was saved." else "")
},
confirmButton = {
Button(onClick = { accountManager.clearStorageCorruption() }) { Text("OK") }
},
)
}
Files Modified
| File | Lines | Changes |
|---|---|---|
AccountManager.kt |
233-247, 460-492, 561-589, 788-872 | Rewrite loadSavedAccount, saveCurrentAccount, logout, NWC methods; remove 8 legacy helpers |
DesktopAccountStorage.kt |
114-126, 223-258 | Corruption backup; add nwcPubKey/nwcRelay to AccountInfoDto |
Main.kt |
835-852 | Remove hasBunkerAccount check; add corruption warning dialog |
Files Deleted (Legacy, stop reading/writing)
| File | Content | Risk |
|---|---|---|
~/.amethyst/nwc_connection.txt |
NWC URI with spending secret | CRITICAL — deleted on startup |
~/.amethyst/last_account.txt |
Last active npub | Low — deleted on startup |
~/.amethyst/bunker_uri.txt |
Bunker URI | Medium — deleted on startup |
Test Cases
| # | Test | File | Type |
|---|---|---|---|
| 1 | nsec login → save → cold boot loads Internal | AccountManagerColdBootTest.kt |
Unit |
| 2 | bunker login → save → cold boot loads Remote with URI | AccountManagerColdBootTest.kt |
Unit |
| 3 | bunker login → switch to nsec → cold boot loads Internal | AccountManagerColdBootTest.kt |
Unit |
| 4 | Two bunker accounts → switch → each uses own URI | AccountManagerSwitchTest.kt |
Unit |
| 5 | NWC set for account A → switch to B → back to A → NWC restored | AccountManagerNwcTest.kt |
Unit |
| 6 | NWC secret stored in keychain, NOT in any file | AccountManagerNwcTest.kt |
Unit |
| 7 | logout(deleteKey=true) removes from metadata + keychain | AccountManagerLogoutTest.kt |
Unit |
| 8 | Corrupted accounts.json.enc → .bak created, empty returned | DesktopAccountStorageCorruptionTest.kt |
Unit |
| 9 | Legacy files deleted on first cold boot | AccountManagerMigrationTest.kt |
Unit |
| 10 | Fresh install → "No saved account" | AccountManagerColdBootTest.kt |
Unit |
Acceptance Criteria
nwc_connection.txtnever written; deleted on startup if existslast_account.txtnever written; deleted on startup if existsbunker_uri.txtnever written; deleted on startup if exists- NWC secret stored via
secureStorage.savePrivateKey("nwc_<npub>", secret) loadSavedAccount()routes bySignerTypefromaccounts.json.enclogout(deleteKey=true)removes account fromaccounts.json.enc- Corrupted
accounts.json.enc→.bakbackup + warning dialog switchAccount()loads correct NWC per-account- All 10 test cases pass
./gradlew spotlessCheckpasses- No plaintext secrets in
~/.amethyst/directory
Risk Analysis
| Risk | Severity | Mitigation |
|---|---|---|
| Existing users lose NWC connection | Low | Acceptable — reconnect wallet once (brainstorm decision) |
| Existing users lose account on cold boot | Medium | accounts.json.enc already stores everything; only users without it are affected (new installs) |
| SecureKeyStorage fallback password prompt for NWC | Low | Same pattern as nsec storage — well-tested |
| Jackson backward compat for new AccountInfoDto fields | Low | Nullable fields with defaults — Jackson handles gracefully |
Sources
- Origin brainstorm: docs/brainstorms/2026-05-14-fix-account-security-hardening-brainstorm.md
AccountManager.kt— cold boot (233-247), save (460-492), logout (561-589), NWC (788-844), legacy files (846-872)DesktopAccountStorage.kt— corruption (114-126), AccountInfoDto (223-258)Main.kt— startup (831-853)SecureKeyStorage.kt— savePrivateKey/getPrivateKey/deletePrivateKey (commons/jvmMain)