From 197db2361d53587fcafd9c2ed6fa42251fbd03a6 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Apr 2026 07:34:07 +0300 Subject: [PATCH] feat(multi-account): add DesktopAccountStorage with AES-256-GCM encryption Phase 2: encrypted persistence for multi-account metadata. - DesktopAccountStorage implements AccountStorage interface - AES-256-GCM encryption with random key stored in OS keychain - Jackson DTOs for flat serialization (no polymorphic complexity) - Migration helper from legacy single-account files - Atomic writes via temp file + rename - POSIX file permissions (owner-only read/write) - 11 unit tests covering roundtrip, encryption, deletion, migration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/account/DesktopAccountStorage.kt | 277 ++++++++++++++++++ .../account/DesktopAccountStorageTest.kt | 200 +++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt new file mode 100644 index 000000000..d2ca9917a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.account + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.AccountStorage +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Encrypted account metadata storage for Desktop. + * + * Architecture: + * - Account metadata (npub, signerType, active account) stored in encrypted JSON file + * - Private keys (nsecs) stored separately in SecureKeyStorage (OS keychain) + * - AES-256-GCM encryption key stored in OS keychain via SecureKeyStorage + * + * File: ~/.amethyst/accounts.json.enc + */ +class DesktopAccountStorage( + private val secureStorage: SecureKeyStorage, + private val homeDir: File = File(System.getProperty("user.home")), +) : AccountStorage { + companion object { + private const val METADATA_KEY_ALIAS = "account-metadata-key" + private const val ACCOUNTS_FILE = "accounts.json.enc" + private const val AES_KEY_SIZE = 32 // 256 bits + private const val GCM_IV_SIZE = 12 + private const val GCM_TAG_BITS = 128 + } + + private val mapper = jacksonObjectMapper() + private val amethystDir by lazy { File(homeDir, ".amethyst") } + + // --- AccountStorage interface --- + + override suspend fun loadAccounts(): List = readMetadata().accounts.map { it.toAccountInfo() } + + override suspend fun saveAccount(info: AccountInfo) { + val metadata = readMetadata() + val dto = AccountInfoDto.from(info) + val updated = metadata.accounts.filter { it.npub != info.npub } + dto + writeMetadata(metadata.copy(accounts = updated)) + } + + override suspend fun deleteAccount(npub: String) { + val metadata = readMetadata() + val updated = metadata.accounts.filter { it.npub != npub } + val newActive = + if (metadata.activeNpub == npub) { + updated.firstOrNull()?.npub + } else { + metadata.activeNpub + } + writeMetadata(metadata.copy(accounts = updated, activeNpub = newActive)) + } + + override suspend fun currentAccount(): String? = readMetadata().activeNpub + + override suspend fun setCurrentAccount(npub: String) { + val metadata = readMetadata() + writeMetadata(metadata.copy(activeNpub = npub)) + } + + // --- Migration from single-account files --- + + suspend fun migrateFromLegacyFiles(accountManager: AccountManager): Boolean { + val prefsFile = File(amethystDir, "last_account.txt") + val bunkerFile = File(amethystDir, "bunker_uri.txt") + + if (!prefsFile.exists() && !bunkerFile.exists()) return false + if (getAccountsFile().exists()) return false // already migrated + + val npub = + prefsFile + .takeIf { it.exists() } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: return false + + val bunkerUri = + bunkerFile + .takeIf { it.exists() } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } + + val signerType = + if (bunkerUri != null) { + SignerType.Remote(bunkerUri) + } else { + val hasPrivKey = secureStorage.hasPrivateKey(npub) + if (hasPrivKey) SignerType.Internal else SignerType.ViewOnly + } + + val info = AccountInfo(npub = npub, signerType = signerType) + val metadata = AccountMetadata(accounts = listOf(AccountInfoDto.from(info)), activeNpub = npub) + writeMetadata(metadata) + + // Verify migration by reading back + val verified = readMetadata() + if (verified.accounts.any { it.npub == npub }) { + // Migration verified — rename old files + prefsFile.renameTo(File(amethystDir, "last_account.txt.bak")) + bunkerFile.takeIf { it.exists() }?.renameTo(File(amethystDir, "bunker_uri.txt.bak")) + Log.d("DesktopAccountStorage", "Migrated legacy account: $npub") + return true + } + + return false + } + + // --- Encrypted file I/O --- + + private suspend fun readMetadata(): AccountMetadata { + val file = getAccountsFile() + if (!file.exists()) return AccountMetadata() + + return try { + val encrypted = file.readBytes() + val decrypted = decrypt(encrypted) + mapper.readValue(decrypted) + } catch (e: Exception) { + Log.e("DesktopAccountStorage", "Failed to read accounts metadata", e) + AccountMetadata() + } + } + + private suspend fun writeMetadata(metadata: AccountMetadata) { + ensureDir() + val json = mapper.writeValueAsBytes(metadata) + val encrypted = encrypt(json) + + // Atomic write via temp file + val file = getAccountsFile() + val temp = File(amethystDir, "${ACCOUNTS_FILE}.tmp") + temp.writeBytes(encrypted) + temp.renameTo(file) + + setFilePermissions(file) + } + + private fun getAccountsFile() = File(amethystDir, ACCOUNTS_FILE) + + // --- AES-256-GCM encryption --- + + private suspend fun getOrCreateKey(): ByteArray { + val existing = secureStorage.getPrivateKey(METADATA_KEY_ALIAS) + if (existing != null) return Base64.getDecoder().decode(existing) + + val key = ByteArray(AES_KEY_SIZE).also { SecureRandom().nextBytes(it) } + secureStorage.savePrivateKey(METADATA_KEY_ALIAS, Base64.getEncoder().encodeToString(key)) + return key + } + + private suspend fun encrypt(data: ByteArray): ByteArray { + val key = getOrCreateKey() + val iv = ByteArray(GCM_IV_SIZE).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + val ciphertext = cipher.doFinal(data) + // Prepend IV to ciphertext + return iv + ciphertext + } + + private suspend fun decrypt(data: ByteArray): ByteArray { + val key = getOrCreateKey() + val iv = data.copyOfRange(0, GCM_IV_SIZE) + val ciphertext = data.copyOfRange(GCM_IV_SIZE, data.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(ciphertext) + } + + // --- File system helpers --- + + private fun ensureDir() { + if (!amethystDir.exists()) amethystDir.mkdirs() + try { + Files.setPosixFilePermissions( + amethystDir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } catch (_: Exception) { + } + } + + private fun setFilePermissions(file: File) { + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } catch (_: UnsupportedOperationException) { + } catch (_: Exception) { + } + } +} + +/** + * Internal DTO for JSON serialization. + * Uses flat fields instead of polymorphic SignerType to keep serialization simple. + */ +internal data class AccountMetadata( + val accounts: List = emptyList(), + val activeNpub: String? = null, +) + +internal data class AccountInfoDto( + val npub: String, + val signerKind: String, // "internal", "remote", "viewonly" + val bunkerUri: String? = null, + val isTransient: Boolean = false, +) { + fun toAccountInfo(): AccountInfo = + AccountInfo( + npub = npub, + signerType = + when (signerKind) { + "remote" -> SignerType.Remote(bunkerUri ?: "") + "viewonly" -> SignerType.ViewOnly + else -> SignerType.Internal + }, + isTransient = isTransient, + ) + + companion object { + fun from(info: AccountInfo): AccountInfoDto = + AccountInfoDto( + npub = info.npub, + signerKind = + when (info.signerType) { + is SignerType.Internal -> "internal" + is SignerType.Remote -> "remote" + is SignerType.ViewOnly -> "viewonly" + }, + bunkerUri = (info.signerType as? SignerType.Remote)?.bunkerUri, + isTransient = info.isTransient, + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt new file mode 100644 index 000000000..9ffd9ac1b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorageTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAccountStorageTest { + private lateinit var tempDir: File + private lateinit var secureStorage: SecureKeyStorage + private lateinit var storage: DesktopAccountStorage + + // In-memory key store for tests + private val keyStore = mutableMapOf() + + @BeforeTest + fun setup() { + tempDir = File(System.getProperty("java.io.tmpdir"), "amethyst-test-${System.nanoTime()}") + tempDir.mkdirs() + + secureStorage = mockk() + val keySlot = slot() + val valueSlot = slot() + + coEvery { secureStorage.getPrivateKey(capture(keySlot)) } answers { + keyStore[keySlot.captured] + } + coEvery { secureStorage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers { + keyStore[keySlot.captured] = valueSlot.captured + } + coEvery { secureStorage.hasPrivateKey(any()) } answers { + keyStore.containsKey(firstArg()) + } + + storage = DesktopAccountStorage(secureStorage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + keyStore.clear() + } + + @Test + fun `empty storage returns no accounts`() = + runTest { + val accounts = storage.loadAccounts() + assertTrue(accounts.isEmpty()) + assertNull(storage.currentAccount()) + } + + @Test + fun `save and load account roundtrip`() = + runTest { + val info = AccountInfo(npub = "npub1test123", signerType = SignerType.Internal) + storage.saveAccount(info) + + val loaded = storage.loadAccounts() + assertEquals(1, loaded.size) + assertEquals("npub1test123", loaded[0].npub) + assertEquals(SignerType.Internal, loaded[0].signerType) + } + + @Test + fun `save remote account preserves bunker URI`() = + runTest { + val bunkerUri = "bunker://abc123?relay=wss://relay.example.com" + val info = AccountInfo(npub = "npub1remote", signerType = SignerType.Remote(bunkerUri)) + storage.saveAccount(info) + + val loaded = storage.loadAccounts() + assertEquals(1, loaded.size) + val loadedType = loaded[0].signerType + assertTrue(loadedType is SignerType.Remote) + assertEquals(bunkerUri, loadedType.bunkerUri) + } + + @Test + fun `save view-only account roundtrip`() = + runTest { + val info = AccountInfo(npub = "npub1viewonly", signerType = SignerType.ViewOnly) + storage.saveAccount(info) + + val loaded = storage.loadAccounts() + assertEquals(1, loaded.size) + assertEquals(SignerType.ViewOnly, loaded[0].signerType) + } + + @Test + fun `save multiple accounts`() = + runTest { + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + storage.saveAccount(AccountInfo("npub1b", SignerType.ViewOnly)) + storage.saveAccount(AccountInfo("npub1c", SignerType.Remote("bunker://x"))) + + val loaded = storage.loadAccounts() + assertEquals(3, loaded.size) + } + + @Test + fun `update existing account replaces it`() = + runTest { + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + storage.saveAccount(AccountInfo("npub1a", SignerType.ViewOnly)) + + val loaded = storage.loadAccounts() + assertEquals(1, loaded.size) + assertEquals(SignerType.ViewOnly, loaded[0].signerType) + } + + @Test + fun `delete account removes it`() = + runTest { + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + storage.saveAccount(AccountInfo("npub1b", SignerType.ViewOnly)) + + storage.deleteAccount("npub1a") + + val loaded = storage.loadAccounts() + assertEquals(1, loaded.size) + assertEquals("npub1b", loaded[0].npub) + } + + @Test + fun `delete active account falls back to first remaining`() = + runTest { + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + storage.saveAccount(AccountInfo("npub1b", SignerType.ViewOnly)) + storage.setCurrentAccount("npub1a") + + storage.deleteAccount("npub1a") + + assertEquals("npub1b", storage.currentAccount()) + } + + @Test + fun `set and get current account`() = + runTest { + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + storage.setCurrentAccount("npub1a") + + assertEquals("npub1a", storage.currentAccount()) + } + + @Test + fun `encryption key is generated and reused`() = + runTest { + storage.saveAccount(AccountInfo("npub1a", SignerType.Internal)) + storage.saveAccount(AccountInfo("npub1b", SignerType.ViewOnly)) + + // Key should be saved once and reused + coVerify(atMost = 1) { + secureStorage.savePrivateKey("account-metadata-key", any()) + } + } + + @Test + fun `encrypted file is not readable as plaintext`() = + runTest { + storage.saveAccount(AccountInfo("npub1secret", SignerType.Internal)) + + val file = File(File(tempDir, ".amethyst"), "accounts.json.enc") + assertTrue(file.exists()) + + val content = file.readText() + // Encrypted content should NOT contain the npub in plaintext + assertTrue(!content.contains("npub1secret")) + } +}