feat(multi-account): add account switching, storage, and migration to AccountManager
Phase 3: wire multi-account lifecycle into desktop AccountManager. - Add DesktopAccountStorage field and allAccounts StateFlow - switchAccount(): loads new account BEFORE cancelling old state (prevents unrecoverable partial failure per coroutines review) - addAccountToStorage / removeAccountFromStorage for CRUD - loadViewOnlyAccount() for npub-only accounts - migrateAndLoadAccounts() for legacy file migration on startup - saveCurrentAccount / saveBunkerAccount now save to encrypted storage - Updated test mock setup to support SecureKeyStorage key store Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+118
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase
|
|||||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
|
||||||
import com.vitorpamplona.amethyst.commons.model.account.SignerType
|
import com.vitorpamplona.amethyst.commons.model.account.SignerType
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
@@ -50,6 +51,9 @@ import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
|||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -92,6 +96,11 @@ class AccountManager internal constructor(
|
|||||||
return AccountManager(storage)
|
return AccountManager(storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createWithStorage(
|
||||||
|
secureStorage: SecureKeyStorage,
|
||||||
|
homeDir: File = File(System.getProperty("user.home")),
|
||||||
|
): AccountManager = AccountManager(secureStorage, homeDir)
|
||||||
|
|
||||||
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
||||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||||
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
||||||
@@ -103,6 +112,13 @@ class AccountManager internal constructor(
|
|||||||
File(homeDir, ".amethyst")
|
File(homeDir, ".amethyst")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val accountStorage: DesktopAccountStorage by lazy {
|
||||||
|
DesktopAccountStorage(secureStorage, homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _allAccounts = MutableStateFlow<ImmutableList<AccountInfo>>(persistentListOf())
|
||||||
|
val allAccounts: StateFlow<ImmutableList<AccountInfo>> = _allAccounts.asStateFlow()
|
||||||
|
|
||||||
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
||||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
||||||
|
|
||||||
@@ -421,6 +437,11 @@ class AccountManager internal constructor(
|
|||||||
saveLastNpub(npub)
|
saveLastNpub(npub)
|
||||||
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
|
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
|
||||||
saveBunkerUri(bunkerUri)
|
saveBunkerUri(bunkerUri)
|
||||||
|
|
||||||
|
// Also save to multi-account storage
|
||||||
|
val info = AccountInfo(npub = npub, signerType = SignerType.Remote(bunkerUri))
|
||||||
|
addAccountToStorage(info)
|
||||||
|
accountStorage.setCurrentAccount(npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
|
fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
|
||||||
@@ -448,6 +469,12 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
secureStorage.savePrivateKey(current.npub, privKeyHex)
|
secureStorage.savePrivateKey(current.npub, privKeyHex)
|
||||||
saveLastNpub(current.npub)
|
saveLastNpub(current.npub)
|
||||||
|
|
||||||
|
// Also save to multi-account storage
|
||||||
|
val info = AccountInfo(npub = current.npub, signerType = current.signerType)
|
||||||
|
addAccountToStorage(info)
|
||||||
|
accountStorage.setCurrentAccount(current.npub)
|
||||||
|
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: SecureStorageException) {
|
} catch (e: SecureStorageException) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
@@ -597,6 +624,97 @@ class AccountManager internal constructor(
|
|||||||
heartbeatJob = null
|
heartbeatJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Multi-account management ---
|
||||||
|
|
||||||
|
suspend fun refreshAccountList() {
|
||||||
|
_allAccounts.value = accountStorage.loadAccounts().toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addAccountToStorage(info: AccountInfo) {
|
||||||
|
accountStorage.saveAccount(info)
|
||||||
|
refreshAccountList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeAccountFromStorage(npub: String) {
|
||||||
|
val current = currentAccount()
|
||||||
|
accountStorage.deleteAccount(npub)
|
||||||
|
|
||||||
|
// Clean up keys
|
||||||
|
try {
|
||||||
|
secureStorage.deletePrivateKey(npub)
|
||||||
|
} catch (_: SecureStorageException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAccountList()
|
||||||
|
|
||||||
|
// If we removed the active account, load the next one or log out
|
||||||
|
if (current?.npub == npub) {
|
||||||
|
val next = accountStorage.currentAccount()
|
||||||
|
if (next != null) {
|
||||||
|
loadSavedAccount()
|
||||||
|
} else {
|
||||||
|
logout(deleteKey = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different account.
|
||||||
|
* Critical: loads new account BEFORE cancelling old state to prevent
|
||||||
|
* unrecoverable partial failure.
|
||||||
|
*/
|
||||||
|
suspend fun switchAccount(targetNpub: String): Result<AccountState.LoggedIn> {
|
||||||
|
val accounts = accountStorage.loadAccounts()
|
||||||
|
val target =
|
||||||
|
accounts.find { it.npub == targetNpub }
|
||||||
|
?: return Result.failure(Exception("Account not found: $targetNpub"))
|
||||||
|
|
||||||
|
// Phase 1: load + validate new account BEFORE touching current state
|
||||||
|
val sType = target.signerType
|
||||||
|
val newState =
|
||||||
|
when (sType) {
|
||||||
|
is SignerType.Internal -> loadInternalAccount(target.npub)
|
||||||
|
is SignerType.Remote -> loadBunkerAccount(sType.bunkerUri, target.npub)
|
||||||
|
is SignerType.ViewOnly -> loadViewOnlyAccount(target.npub)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState.isFailure) return newState
|
||||||
|
|
||||||
|
// Phase 2: transition succeeded — update storage and cleanup
|
||||||
|
accountStorage.setCurrentAccount(targetNpub)
|
||||||
|
|
||||||
|
// Reload NWC for the new account
|
||||||
|
loadNwcConnection()
|
||||||
|
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadViewOnlyAccount(npub: String): Result<AccountState.LoggedIn> {
|
||||||
|
val pubKeyHex =
|
||||||
|
decodePublicKeyAsHexOrNull(npub)
|
||||||
|
?: return Result.failure(Exception("Invalid npub: $npub"))
|
||||||
|
|
||||||
|
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
|
||||||
|
val signer = NostrSignerInternal(keyPair)
|
||||||
|
|
||||||
|
val state =
|
||||||
|
AccountState.LoggedIn(
|
||||||
|
signer = signer,
|
||||||
|
pubKeyHex = pubKeyHex,
|
||||||
|
npub = npub,
|
||||||
|
nsec = null,
|
||||||
|
isReadOnly = true,
|
||||||
|
signerType = SignerType.ViewOnly,
|
||||||
|
)
|
||||||
|
_accountState.value = state
|
||||||
|
return Result.success(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun migrateAndLoadAccounts() {
|
||||||
|
accountStorage.migrateFromLegacyFiles(this)
|
||||||
|
refreshAccountList()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Accessors ---
|
// --- Accessors ---
|
||||||
|
|
||||||
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
|
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
|
||||||
|
|||||||
+15
@@ -26,8 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
|||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||||
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.io.path.createTempDirectory
|
import kotlin.io.path.createTempDirectory
|
||||||
@@ -45,9 +47,22 @@ class AccountManagerKeyLoginTest {
|
|||||||
private lateinit var tempDir: File
|
private lateinit var tempDir: File
|
||||||
private lateinit var manager: AccountManager
|
private lateinit var manager: AccountManager
|
||||||
|
|
||||||
|
private val keyStore = mutableMapOf<String, String>()
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
|
keyStore.clear()
|
||||||
storage = mockk(relaxed = true)
|
storage = mockk(relaxed = true)
|
||||||
|
|
||||||
|
// Wire up key store so DesktopAccountStorage encryption works
|
||||||
|
val keySlot = slot<String>()
|
||||||
|
val valueSlot = slot<String>()
|
||||||
|
coEvery { storage.getPrivateKey(capture(keySlot)) } answers { keyStore[keySlot.captured] }
|
||||||
|
coEvery { storage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers {
|
||||||
|
keyStore[keySlot.captured] = valueSlot.captured
|
||||||
|
}
|
||||||
|
coEvery { storage.hasPrivateKey(any()) } answers { keyStore.containsKey(firstArg()) }
|
||||||
|
|
||||||
tempDir = createTempDirectory("acctmgr-key-test").toFile()
|
tempDir = createTempDirectory("acctmgr-key-test").toFile()
|
||||||
manager = AccountManager(storage, tempDir)
|
manager = AccountManager(storage, tempDir)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user