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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-24 07:34:07 +03:00
parent aa33b7d271
commit 197db2361d
2 changed files with 477 additions and 0 deletions
@@ -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<AccountInfo> = 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<AccountMetadata>(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<AccountInfoDto> = 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,
)
}
}
@@ -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<String, String>()
@BeforeTest
fun setup() {
tempDir = File(System.getProperty("java.io.tmpdir"), "amethyst-test-${System.nanoTime()}")
tempDir.mkdirs()
secureStorage = mockk()
val keySlot = slot<String>()
val valueSlot = slot<String>()
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"))
}
}