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:
+277
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+200
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user