fix(desktop): harden account security — NWC to keychain, single source of truth

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>
This commit is contained in:
nrobi144
2026-05-15 09:41:17 +03:00
parent 4cdf8c3ec2
commit 46caa4d795
9 changed files with 709 additions and 176 deletions
@@ -887,24 +887,20 @@ fun App(
// Load account list from encrypted storage
accountManager.refreshAccountListOnStartup()
if (accountManager.hasBunkerAccount()) {
// Show connecting UI while dedicated NIP-46 client connects
accountManager.setConnectingRelays()
}
val result = accountManager.loadSavedAccount()
if (result.isSuccess) {
// Ensure loaded account is in multi-account storage
accountManager.ensureCurrentAccountInStorage()
accountManager.refreshAccountList()
val current = accountManager.currentAccount()
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
} else if (accountManager.hasBunkerAccount()) {
// Corrupt bunker state — fall back to login screen
accountManager.logout(deleteKey = true)
// Load per-account NWC
if (current != null) {
accountManager.loadNwcConnection(current.npub)
}
}
// If failure: state remains LoggedOut → login screen shows automatically
}
onDispose {
@@ -965,18 +961,14 @@ fun App(
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Lazy-load Namecoin services — almost never used, no need to keep in
// memory from the start (matches Android lazy pattern)
// Lazy-load Namecoin services
val namecoinPreferences = remember { DesktopNamecoinPreferences() }
val namecoinService =
remember {
DesktopNamecoinNameService(preferencesProvider = { namecoinPreferences.current })
}
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
// NWC loaded during startup in loadSavedAccount flow
val currentTorStatus = torManager.status.collectAsState().value
androidx.compose.runtime.CompositionLocalProvider(
@@ -1498,10 +1490,7 @@ fun RelaySettingsScreen(
var nwcInput by remember { mutableStateOf("") }
var nwcError by remember { mutableStateOf<String?>(null) }
// Load NWC on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
val nwcScope = rememberCoroutineScope()
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
@@ -1567,7 +1556,7 @@ fun RelaySettingsScreen(
)
}
OutlinedButton(
onClick = { accountManager.clearNwcConnection() },
onClick = { nwcScope.launch { accountManager.clearNwcConnection(account.npub) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
@@ -1597,11 +1586,13 @@ fun RelaySettingsScreen(
)
Button(
onClick = {
val result = accountManager.setNwcConnection(nwcInput)
result.fold(
onSuccess = { nwcInput = "" },
onFailure = { nwcError = it.message ?: "Invalid connection string" },
)
nwcScope.launch {
val result = accountManager.setNwcConnection(account.npub, nwcInput)
result.fold(
onSuccess = { nwcInput = "" },
onFailure = { nwcError = it.message ?: "Invalid connection string" },
)
}
},
enabled = nwcInput.isNotBlank(),
) {
@@ -67,8 +67,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission
sealed class AccountState {
data object LoggedOut : AccountState()
@@ -111,8 +109,15 @@ class AccountManager internal constructor(
File(homeDir, ".amethyst")
}
private val _storageCorruption = MutableStateFlow<StorageCorruption?>(null)
val storageCorruption: StateFlow<StorageCorruption?> = _storageCorruption.asStateFlow()
fun clearStorageCorruption() {
_storageCorruption.value = null
}
val accountStorage: DesktopAccountStorage by lazy {
DesktopAccountStorage(secureStorage, homeDir)
DesktopAccountStorage(secureStorage, homeDir, onCorruption = { _storageCorruption.value = it })
}
private val _allAccounts = MutableStateFlow<ImmutableList<AccountInfo>>(persistentListOf())
@@ -232,15 +237,25 @@ class AccountManager internal constructor(
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
try {
val lastNpub = getLastNpub()
val bunkerUri = getBunkerUri()
// Clean up legacy files (one-time, silent)
File(amethystDir, "last_account.txt").delete()
File(amethystDir, "bunker_uri.txt").delete()
File(amethystDir, "nwc_connection.txt").delete()
if (bunkerUri != null) {
loadBunkerAccount(bunkerUri, lastNpub)
} else if (lastNpub != null) {
loadInternalAccount(lastNpub)
} else {
Result.failure(Exception("No saved account"))
// Single source of truth: accounts.json.enc
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"))
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)
@@ -298,7 +313,6 @@ class AccountManager internal constructor(
}
val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub()
if (npub == null) saveLastNpub(resolvedNpub)
val state =
AccountState.LoggedIn(
@@ -439,18 +453,14 @@ class AccountManager internal constructor(
ephemeralPrivKeyHex: String,
npub: String,
) {
saveLastNpub(npub)
secureStorage.savePrivateKey(bunkerEphemeralKeyAlias(npub), ephemeralPrivKeyHex)
saveBunkerUri(bunkerUri)
// Also save to multi-account storage
// Save to multi-account storage (sole source of truth)
val info = AccountInfo(npub = npub, signerType = SignerType.Remote(bunkerUri))
addAccountToStorage(info)
accountStorage.setCurrentAccount(npub)
}
fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
fun setConnectingRelays() {
_accountState.value = AccountState.ConnectingRelays
}
@@ -481,8 +491,6 @@ class AccountManager internal constructor(
}
}
saveLastNpub(current.npub)
// Always save to multi-account storage (including read-only)
val info = AccountInfo(npub = current.npub, signerType = current.signerType)
addAccountToStorage(info)
@@ -564,23 +572,30 @@ class AccountManager internal constructor(
// Clean up remote signer if bunker account
if (current.signerType is SignerType.Remote) {
(current.signer as? NostrSignerRemote)?.closeSubscription()
if (deleteKey) {
try {
secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(current.npub))
} catch (_: SecureStorageException) {
}
getBunkerFile().delete()
}
}
if (deleteKey) {
// Remove ALL credentials for this account
try {
secureStorage.deletePrivateKey(current.npub)
clearLastNpub()
} catch (_: SecureStorageException) {
}
try {
secureStorage.deletePrivateKey(bunkerEphemeralKeyAlias(current.npub))
} catch (_: SecureStorageException) {
}
try {
secureStorage.deletePrivateKey(nwcKeyAlias(current.npub))
} catch (_: SecureStorageException) {
}
// Remove from metadata — fixes ghost accounts
try {
accountStorage.deleteAccount(current.npub)
} catch (_: Exception) {
}
}
}
disconnectNip46Client()
_nwcConnection.value = null
_signerConnectionState.value = SignerConnectionState.NotRemote
_lastPingTimeSec.value = null
_accountState.value = AccountState.LoggedOut
@@ -758,7 +773,7 @@ class AccountManager internal constructor(
}
// Reload NWC for the new account
loadNwcConnection()
loadNwcConnection(targetNpub)
return newState
}
@@ -785,91 +800,60 @@ class AccountManager internal constructor(
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
// --- NWC ---
// --- NWC (per-account, secret in keychain) ---
private fun nwcKeyAlias(npub: String) = "nwc_$npub"
fun hasNwcSetup(): Boolean = _nwcConnection.value != null
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
suspend fun setNwcConnection(
npub: String,
uri: String,
): Result<Nip47WalletConnect.Nip47URINorm> =
try {
val parsed = Nip47WalletConnect.parse(uri)
if (parsed.secret == null) throw IllegalArgumentException("NWC URI has no secret")
// Store entire URI in keychain (not plaintext file)
secureStorage.savePrivateKey(nwcKeyAlias(npub), uri)
_nwcConnection.value = parsed
saveNwcUri(uri)
Result.success(parsed)
} catch (e: Exception) {
Result.failure(e)
}
fun clearNwcConnection() {
_nwcConnection.value = null
getNwcFile().delete()
}
fun loadNwcConnection() {
val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim()
if (!uri.isNullOrEmpty()) {
try {
_nwcConnection.value = Nip47WalletConnect.parse(uri)
} catch (_: Exception) {
getNwcFile().delete()
}
}
}
// --- File storage helpers ---
private fun ensureAmethystDir() {
if (!amethystDir.exists()) {
amethystDir.mkdirs()
}
suspend fun clearNwcConnection(npub: String) {
try {
Files.setPosixFilePermissions(
amethystDir.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows — file system ACLs handle this
secureStorage.deletePrivateKey(nwcKeyAlias(npub))
} catch (_: SecureStorageException) {
}
_nwcConnection.value = null
}
suspend fun loadNwcConnection(npub: String) {
val secret =
try {
secureStorage.getPrivateKey(nwcKeyAlias(npub))
} catch (_: SecureStorageException) {
null
}
if (secret == null) {
_nwcConnection.value = null
return
}
// Reconstruct Nip47URINorm from the NWC URI stored in the keychain.
// The secret is the full NWC URI in hex form.
// For simplicity, the entire NWC URI is stored in keychain (not just the secret).
// This avoids needing to persist pubkey+relay separately.
try {
_nwcConnection.value = Nip47WalletConnect.parse(secret)
} catch (_: Exception) {
_nwcConnection.value = null
}
}
private fun saveNwcUri(uri: String) {
ensureAmethystDir()
getNwcFile().writeText(uri)
}
private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt")
private fun getLastNpub(): String? {
val file = getPrefsFile()
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
}
private fun saveLastNpub(npub: String) {
ensureAmethystDir()
getPrefsFile().writeText(npub)
}
private fun clearLastNpub() {
getPrefsFile().delete()
}
private fun getPrefsFile(): File = File(amethystDir, "last_account.txt")
private fun getBunkerUri(): String? {
val file = getBunkerFile()
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
}
private fun saveBunkerUri(uri: String) {
ensureAmethystDir()
getBunkerFile().writeText(uri)
}
private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt")
}
internal fun parseBunkerRelays(uri: String): Set<NormalizedRelayUrl> {
@@ -47,9 +47,22 @@ import javax.crypto.spec.SecretKeySpec
*
* File: ~/.amethyst/accounts.json.enc
*/
sealed class StorageCorruption(
val backupPath: String?,
) {
class FileCorrupted(
backupPath: String?,
) : StorageCorruption(backupPath)
class JsonMalformed(
backupPath: String?,
) : StorageCorruption(backupPath)
}
class DesktopAccountStorage(
private val secureStorage: SecureKeyStorage,
private val homeDir: File = File(System.getProperty("user.home")),
private val onCorruption: (StorageCorruption) -> Unit = {},
) : AccountStorage {
companion object {
private const val METADATA_KEY_ALIAS = "account-metadata-key"
@@ -115,16 +128,50 @@ class DesktopAccountStorage(
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 encrypted = file.readBytes()
val decrypted = decrypt(encrypted)
mapper.readValue<AccountMetadata>(decrypted)
} catch (e: javax.crypto.AEADBadTagException) {
Log.e("DesktopAccountStorage", "GCM auth tag mismatch — file corrupted or key lost", e)
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.FileCorrupted(backup))
AccountMetadata()
} catch (e: javax.crypto.BadPaddingException) {
Log.e("DesktopAccountStorage", "Decryption failed — file corrupted", e)
val backup = backupCorruptFile(file)
onCorruption(StorageCorruption.FileCorrupted(backup))
AccountMetadata()
} catch (e: com.fasterxml.jackson.core.JacksonException) {
Log.e("DesktopAccountStorage", "JSON malformed after decryption", e)
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()}")
java.nio.file.Files
.copy(file.toPath(), backup.toPath())
file.delete()
backup.absolutePath
} catch (_: Exception) {
null
}
private suspend fun writeMetadataToDisk(metadata: AccountMetadata) {
ensureDir()
val json = mapper.writeValueAsBytes(metadata)
@@ -226,6 +273,8 @@ internal data class AccountInfoDto(
val bunkerUri: String? = null,
val displayName: String? = null,
val isTransient: Boolean = false,
val nwcPubKey: String? = null, // NWC wallet service pubkey (non-secret)
val nwcRelay: String? = null, // NWC wallet relay URL (non-secret)
) {
fun toAccountInfo(): AccountInfo =
AccountInfo(
@@ -27,7 +27,6 @@ import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AccountManagerBunkerLoginTest {
@@ -50,17 +49,6 @@ class AccountManagerBunkerLoginTest {
tempDir.deleteRecursively()
}
@Test
fun hasBunkerAccountReturnsFalseWhenNoFile() {
assertFalse(manager.hasBunkerAccount())
}
@Test
fun hasBunkerAccountReturnsTrueWhenFileExists() {
File(amethystDir, "bunker_uri.txt").writeText("bunker://${"a".repeat(64)}?relay=wss://r.com")
assertTrue(manager.hasBunkerAccount())
}
@Test
fun setConnectingRelaysUpdatesState() {
manager.setConnectingRelays()
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.model.account.SignerType
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -45,6 +46,8 @@ class AccountManagerLoadAccountTest {
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
// Return null so DesktopAccountStorage generates a fresh AES key
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
tempDir = createTempDirectory("acctmgr-load-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
@@ -57,9 +60,9 @@ class AccountManagerLoadAccountTest {
}
@Test
fun loadSavedAccountNoNpubReturnsFailure() =
fun loadSavedAccountNoAccountsReturnsFailure() =
runTest {
// No last_account.txt file
// Empty accounts.json.enc (no accounts saved)
val result = manager.loadSavedAccount()
assertTrue(result.isFailure)
}
@@ -71,10 +74,11 @@ class AccountManagerLoadAccountTest {
val npub = keyPair.pubKey.toNpub()
val privKeyHex = keyPair.privKey!!.toHexKey()
// Write last_account.txt
File(amethystDir, "last_account.txt").writeText(npub)
// Save account via storage API
manager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.Internal))
manager.accountStorage.setCurrentAccount(npub)
// Mock storage to return the private key
// Mock keychain to return the private key
coEvery { storage.getPrivateKey(npub) } returns privKeyHex
val result = manager.loadSavedAccount()
@@ -90,7 +94,8 @@ class AccountManagerLoadAccountTest {
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
File(amethystDir, "last_account.txt").writeText(npub)
manager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.Internal))
manager.accountStorage.setCurrentAccount(npub)
coEvery { storage.getPrivateKey(npub) } returns null
val result = manager.loadSavedAccount()
@@ -106,10 +111,13 @@ class AccountManagerLoadAccountTest {
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
File(amethystDir, "last_account.txt").writeText(npub)
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://r.com",
manager.accountStorage.saveAccount(
AccountInfo(npub = npub, signerType = SignerType.Remote("bunker://$validHex?relay=wss://r.com")),
)
manager.accountStorage.setCurrentAccount(npub)
coEvery {
storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub))
} returns null
coEvery {
storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS)
} returns null
@@ -121,23 +129,49 @@ class AccountManagerLoadAccountTest {
@Test
fun loadSavedAccountBunkerSuccess() =
runTest {
val walletKeyPair = KeyPair()
val validHex = walletKeyPair.pubKey.toHexKey()
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
val ephemeralKeyPair = KeyPair()
val ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey()
val validHex = keyPair.pubKey.toHexKey()
val ephemeralKey = KeyPair()
File(amethystDir, "last_account.txt").writeText(npub)
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://r.com",
manager.accountStorage.saveAccount(
AccountInfo(npub = npub, signerType = SignerType.Remote("bunker://$validHex?relay=wss://relay.nsec.app")),
)
manager.accountStorage.setCurrentAccount(npub)
coEvery {
storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralPrivKeyHex
storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub))
} returns ephemeralKey.privKey!!.toHexKey()
val result = manager.loadSavedAccount()
assertTrue(result.isSuccess)
val state = result.getOrThrow()
assertIs<SignerType.Remote>(state.signerType)
}
@Test
fun loadSavedAccountDeletesLegacyFiles() =
runTest {
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
val privKeyHex = keyPair.privKey!!.toHexKey()
// Create legacy files
File(amethystDir, "last_account.txt").writeText(npub)
File(amethystDir, "bunker_uri.txt").writeText("bunker://test")
File(amethystDir, "nwc_connection.txt").writeText("nostr+walletconnect://test")
// Save via storage
manager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.Internal))
manager.accountStorage.setCurrentAccount(npub)
coEvery { storage.getPrivateKey(npub) } returns privKeyHex
manager.loadSavedAccount()
// Legacy files should be deleted
assertTrue(!File(amethystDir, "last_account.txt").exists())
assertTrue(!File(amethystDir, "bunker_uri.txt").exists())
assertTrue(!File(amethystDir, "nwc_connection.txt").exists())
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@@ -44,6 +45,7 @@ class AccountManagerLogoutTest {
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
tempDir = createTempDirectory("acctmgr-logout-test").toFile()
manager = AccountManager(storage, tempDir)
}
@@ -55,6 +55,7 @@ class AccountManagerNip46IsolationTest {
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
@@ -69,16 +70,21 @@ class AccountManagerNip46IsolationTest {
@Test
fun bunkerLoadCreatesRemoteSignerInternally() =
runTest {
val validHex = "a".repeat(64)
val walletKeyPair = KeyPair()
val validHex = walletKeyPair.pubKey.toHexKey()
val ephemeralKeyPair = KeyPair()
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://relay.nsec.app",
)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
val npub = ephemeralKeyPair.pubKey.toNpub()
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
manager.accountStorage.saveAccount(
com.vitorpamplona.amethyst.commons.model.account.AccountInfo(
npub = npub,
signerType = SignerType.Remote(bunkerUri),
),
)
manager.accountStorage.setCurrentAccount(npub)
coEvery {
storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS)
storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub))
} returns ephemeralKeyPair.privKey!!.toHexKey()
val result = manager.loadSavedAccount()
@@ -130,16 +136,21 @@ class AccountManagerNip46IsolationTest {
@Test
fun logoutResetsStateAfterBunkerLoad() =
runTest {
val validHex = "a".repeat(64)
val walletKeyPair = KeyPair()
val validHex = walletKeyPair.pubKey.toHexKey()
val ephemeralKeyPair = KeyPair()
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://relay.nsec.app",
)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
val npub = ephemeralKeyPair.pubKey.toNpub()
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
manager.accountStorage.saveAccount(
com.vitorpamplona.amethyst.commons.model.account.AccountInfo(
npub = npub,
signerType = SignerType.Remote(bunkerUri),
),
)
manager.accountStorage.setCurrentAccount(npub)
coEvery {
storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS)
storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub))
} returns ephemeralKeyPair.privKey!!.toHexKey()
manager.loadSavedAccount()
@@ -155,23 +166,28 @@ class AccountManagerNip46IsolationTest {
@Test
fun logoutThenReloadCreatesFreshNip46Client() =
runTest {
val validHex = "a".repeat(64)
val walletKeyPair = KeyPair()
val validHex = walletKeyPair.pubKey.toHexKey()
val ephemeralKeyPair = KeyPair()
val npub = ephemeralKeyPair.pubKey.toNpub()
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
fun writeBunkerFiles() {
File(amethystDir, "bunker_uri.txt").writeText(bunkerUri)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
suspend fun setupBunkerAccount() {
manager.accountStorage.saveAccount(
com.vitorpamplona.amethyst.commons.model.account.AccountInfo(
npub = npub,
signerType = SignerType.Remote(bunkerUri),
),
)
manager.accountStorage.setCurrentAccount(npub)
}
coEvery {
storage.getPrivateKey(AccountManager.LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS)
storage.getPrivateKey(AccountManager.bunkerEphemeralKeyAlias(npub))
} returns ephemeralKeyPair.privKey!!.toHexKey()
// First load
writeBunkerFiles()
setupBunkerAccount()
manager.loadSavedAccount()
val firstSigner = manager.currentAccount()?.signer
assertNotNull(firstSigner)
@@ -181,7 +197,7 @@ class AccountManagerNip46IsolationTest {
assertIs<AccountState.LoggedOut>(manager.accountState.value)
// Second load — should create a fresh NIP-46 client
writeBunkerFiles()
setupBunkerAccount()
manager.loadSavedAccount()
val secondSigner = manager.currentAccount()?.signer
assertNotNull(secondSigner)
@@ -55,6 +55,7 @@ class AccountManagerStateTransitionTest {
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
tempDir = createTempDirectory("acctmgr-state-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
@@ -81,7 +82,13 @@ class AccountManagerStateTransitionTest {
// Then load an internal account (simulating successful load)
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
File(amethystDir, "last_account.txt").writeText(npub)
manager.accountStorage.saveAccount(
com.vitorpamplona.amethyst.commons.model.account.AccountInfo(
npub = npub,
signerType = com.vitorpamplona.amethyst.commons.model.account.SignerType.Internal,
),
)
manager.accountStorage.setCurrentAccount(npub)
coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey()
manager.loadSavedAccount()