diff --git a/CLAUDE.md b/CLAUDE.md index aa4adce3..880b5546 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Git hooks are auto-installed via the root `build.gradle.kts` preBuild task — n ## Modules - `:app` — the Android app (everything below in Architecture refers to it) -- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service via `java-keyring`, file fallback — see `KeystorePassword.resolve`); an opt-in `PassphraseLock` instead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine. State is JSON files per account (no Room). See `desktop/README.md`. +- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service via `java-keyring`, file fallback — see `KeystorePassword.resolve`); an opt-in `PassphraseLock` instead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine — and, while set, encrypts the per-account database (apps/permissions/history/logs) at rest via `writeSecure`/`readSecure`. State is JSON files per account (no Room). See `desktop/README.md`. ## Architecture diff --git a/desktop/README.md b/desktop/README.md index 49ed9741..e89c4883 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -74,6 +74,13 @@ With the lock on: - Copying the data directory (or the credential store) is useless — without the passphrase there is no way to decrypt the keys. +- The per-account **database** (connected applications, permission grants, + request history, relay logs) is also encrypted at rest with the master + key — AES-256-GCM, with an `AMBERENC1:` header — so the metadata about + which apps you sign for stays private too. Enabling the passphrase + re-encrypts existing data immediately; removing it rewrites plaintext. + (`settings.json` and `accounts.json` stay plaintext, but the private keys + inside `accounts.json` are always encrypted with the master key.) - Amber asks for the passphrase at startup and can auto-lock after an idle timeout (5 min / 15 min / 1 hour / never) or immediately via **Lock now**. Locking evicts all key material from memory and disconnects the relays, so diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt index fa9fdc58..10ef1027 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt @@ -78,6 +78,15 @@ object AmberDesktop { accountCache.clear() } + /** + * Rewrites every account's database in the current encryption state. + * Invoked when the passphrase lock is enabled or removed so the on-disk + * apps/history/logs are re-encrypted (or decrypted) immediately. + */ + fun rewriteAllStores() { + AccountsStore.accounts.value.forEach { store(it.npub).rewriteAll() } + } + val settings: DesktopSettings get() = SettingsStore.settings.value fun defaultRelays(): List = settings.normalizedDefaultRelays() diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt index 41158b15..e6ba4cf6 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt @@ -43,36 +43,37 @@ object DesktopKeyStore { class LockedException : IllegalStateException("The key store is locked. Unlock it with your passphrase first.") - suspend fun encrypt(plainText: String): String = mutex.withLock { + suspend fun encrypt(plainText: String): String = mutex.withLock { encryptString(plainText) } + + suspend fun decrypt(encryptedText: String): String = mutex.withLock { decryptString(encryptedText) } + + /** + * Synchronous AES-256-GCM encryption with the master key, for the storage + * layer (which reads/writes files on its own threads). Requires the master + * key to be available — throws [LockedException] when the passphrase lock + * is engaged and the app has not been unlocked. + */ + fun encryptString(plainText: String): String { val key = getOrCreateSecretKey() val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, key) val iv = cipher.iv - val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) - val combined = ByteBuffer.allocate(iv.size + cipherText.size) combined.put(iv) combined.put(cipherText) - return Base64.getEncoder().withoutPadding().encodeToString(combined.array()) } - suspend fun decrypt(encryptedText: String): String = mutex.withLock { + fun decryptString(encryptedText: String): String { val key = getOrCreateSecretKey() val data = Base64.getDecoder().decode(encryptedText) val buffer = ByteBuffer.wrap(data) - val iv = ByteArray(IV_SIZE).also { buffer.get(it) } val cipherText = ByteArray(buffer.remaining()).also { buffer.get(it) } - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(TAG_SIZE, iv) - cipher.init(Cipher.DECRYPT_MODE, key, spec) - - val plainBytes = cipher.doFinal(cipherText) - return String(plainBytes, Charsets.UTF_8) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_SIZE, iv)) + return String(cipher.doFinal(cipherText), Charsets.UTF_8) } // ----- master key management (used by PassphraseLock) ----- @@ -148,6 +149,7 @@ object DesktopKeyStore { AppDirs.restrictToOwner(keyStoreFile) } + @Synchronized private fun getOrCreateSecretKey(): SecretKey { cachedKey?.let { return it } if (PassphraseLock.isEnabled()) { @@ -156,6 +158,9 @@ object DesktopKeyStore { return loadOrCreateFromKeystore() } + /** True when the master key is in memory (or loadable without a passphrase). */ + fun isMasterKeyAvailable(): Boolean = cachedKey != null || !PassphraseLock.isEnabled() + private fun loadOrCreateFromKeystore(): SecretKey { cachedKey?.let { return it } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/PassphraseLock.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/PassphraseLock.kt index 1151525f..97595eb3 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/PassphraseLock.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/PassphraseLock.kt @@ -83,6 +83,8 @@ object PassphraseLock { writeBlob(key, passphrase, params) DesktopKeyStore.removeUnprotectedCopies() DesktopKeyStore.installMasterKey(key, SOURCE_DESCRIPTION) + // isEnabled() is now true, so re-encrypt every account's database at rest. + AmberDesktop.rewriteAllStores() state.value = Status.UNLOCKED scheduleAutoLock() } @@ -118,6 +120,8 @@ object PassphraseLock { val key = DesktopKeyStore.masterKeyForWrapping() DesktopKeyStore.recreateUnprotectedStore(key) blobFile.delete() + // isEnabled() is now false, so rewrite every account's database as plaintext. + AmberDesktop.rewriteAllStores() autoLockJob?.cancel() state.value = Status.DISABLED } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt index 22740958..43737965 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt @@ -8,6 +8,20 @@ import kotlinx.coroutines.flow.MutableStateFlow private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) +/** Header on a file whose JSON payload is AES-GCM encrypted with the master key. */ +private const val ENC_MARKER = "AMBERENC1:" + +private fun atomicWrite(file: File, payload: String) { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(payload) + if (!tmp.renameTo(file)) { + // Windows can refuse an atomic replace; fall back to copy + delete. + file.writeText(payload) + tmp.delete() + } + AppDirs.restrictToOwner(file) +} + private inline fun readJson(file: File): T? = try { if (file.exists()) mapper.readValue(file.readText()) else null } catch (e: Exception) { @@ -16,14 +30,41 @@ private inline fun readJson(file: File): T? = try { } private fun writeJson(file: File, value: Any) { - val tmp = File(file.parentFile, "${file.name}.tmp") - tmp.writeText(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value)) - if (!tmp.renameTo(file)) { - // Windows can refuse an atomic replace; fall back to copy + delete. - file.writeText(tmp.readText()) - tmp.delete() + atomicWrite(file, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value)) +} + +/** + * Reads a JSON file that may be encrypted. Encrypted files carry the + * [ENC_MARKER] header (written while a passphrase is set); plaintext files + * are read as-is, so installs that predate the passphrase migrate + * transparently. + */ +private inline fun readSecure(file: File): T? = try { + if (!file.exists()) { + null + } else { + val raw = file.readText() + val json = if (raw.startsWith(ENC_MARKER)) DesktopKeyStore.decryptString(raw.substring(ENC_MARKER.length)) else raw + mapper.readValue(json) } - AppDirs.restrictToOwner(file) +} catch (e: Exception) { + AmberLogger.e("Storage", "Failed to read ${file.name}", e) + null +} + +/** + * Writes a JSON file, encrypting the payload with the master key whenever a + * passphrase lock is set. Refuses to write while locked rather than clobber + * ciphertext with an empty document. + */ +private fun writeSecure(file: File, value: Any) { + val encrypt = PassphraseLock.isEnabled() + if (encrypt && !DesktopKeyStore.isMasterKeyAvailable()) { + AmberLogger.e("Storage", "Refusing to write ${file.name} while the key store is locked") + return + } + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value) + atomicWrite(file, if (encrypt) ENC_MARKER + DesktopKeyStore.encryptString(json) else json) } object AmberLogger { @@ -49,9 +90,13 @@ class AccountStore(val npub: String) { private val historyFile = File(dir, "history.json") private val logsFile = File(dir, "logs.json") - val apps = MutableStateFlow(readJson>(appsFile) ?: emptyList()) - val history = MutableStateFlow(readJson>(historyFile) ?: emptyList()) - val logs = MutableStateFlow(readJson>(logsFile) ?: emptyList()) + // The per-account database (apps + permissions, request history, relay + // logs) is the sensitive metadata about who you sign for, so it is + // encrypted at rest with the master key whenever a passphrase lock is set + // (see writeSecure/readSecure). + val apps = MutableStateFlow(readSecure>(appsFile) ?: emptyList()) + val history = MutableStateFlow(readSecure>(historyFile) ?: emptyList()) + val logs = MutableStateFlow(readSecure>(logsFile) ?: emptyList()) fun getByKey(key: String): AppWithPermissions? = apps.value.firstOrNull { it.app.key == key } @@ -64,34 +109,47 @@ class AccountStore(val npub: String) { @Synchronized fun upsert(app: AppWithPermissions) { apps.value = apps.value.filter { it.app.key != app.app.key } + app - writeJson(appsFile, apps.value) + writeSecure(appsFile, apps.value) } @Synchronized fun delete(key: String) { apps.value = apps.value.filter { it.app.key != key } history.value = history.value.filter { it.appKey != key } - writeJson(appsFile, apps.value) - writeJson(historyFile, history.value) + writeSecure(appsFile, apps.value) + writeSecure(historyFile, history.value) } @Synchronized fun addHistory(record: HistoryRecord) { history.value = (history.value + record).takeLast(MAX_HISTORY) - writeJson(historyFile, history.value) + writeSecure(historyFile, history.value) } @Synchronized fun addLog(url: String, type: String, message: String) { AmberLogger.d("Amber", "$url: $message") logs.value = (logs.value + LogRecord(url, type, message, System.currentTimeMillis())).takeLast(MAX_LOGS) - writeJson(logsFile, logs.value) + writeSecure(logsFile, logs.value) } @Synchronized fun clearLogs() { logs.value = emptyList() - writeJson(logsFile, logs.value) + writeSecure(logsFile, logs.value) + } + + /** + * Rewrites all files in the current encryption state. Called when the + * passphrase lock is enabled (plaintext → encrypted) or removed + * (encrypted → plaintext) so at-rest data matches immediately rather than + * only on the next natural write. + */ + @Synchronized + fun rewriteAll() { + writeSecure(appsFile, apps.value) + writeSecure(historyFile, history.value) + writeSecure(logsFile, logs.value) } fun deleteAllFiles() { diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DatabaseEncryptionTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DatabaseEncryptionTest.kt new file mode 100644 index 00000000..3b90b948 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DatabaseEncryptionTest.kt @@ -0,0 +1,76 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import com.greenart7c3.nostrsigner.desktop.core.AccountStore +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.AppDirs +import com.greenart7c3.nostrsigner.desktop.core.AppRecord +import com.greenart7c3.nostrsigner.desktop.core.AppWithPermissions +import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore +import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test + +/** + * Verifies that the per-account database (applications/permissions/history/ + * logs) is encrypted at rest once a passphrase lock is set, and migrates + * transparently in both directions. + */ +class DatabaseEncryptionTest { + companion object { + private val fastKdf = PassphraseLock.KdfParams(memoryKb = 1024, iterations = 1, parallelism = 1) + + @JvmStatic + @BeforeClass + fun isolateDataDir() { + val tmp = File.createTempFile("amber-db-enc", "").apply { + delete() + mkdirs() + deleteOnExit() + } + System.setProperty("user.home", tmp.absolutePath) + } + } + + @Test + fun databaseIsEncryptedUnderPassphrase() = runBlocking { + val marker = "SECRET_APP_NAME_9f3a" + val account = AccountManager.addAccount(KeyPair(), name = "db") + val store = AmberDesktop.store(account.npub) + store.upsert(AppWithPermissions(app = AppRecord(key = "app-key-1", name = marker, pubKey = account.hexKey))) + store.addLog("wss://relay.example.com", "bunker", "sensitive-log-line") + + val appsFile = File(AppDirs.accountDir(account.npub), "applications.json") + + // Before any passphrase: plaintext JSON on disk. + assertTrue("app db should be plaintext without a passphrase", appsFile.readText().contains(marker)) + + // Enabling a passphrase re-encrypts the database at rest. + PassphraseLock.enable("correct horse battery".toCharArray(), fastKdf) + val encrypted = appsFile.readText() + assertTrue("encrypted file should carry the marker header", encrypted.startsWith("AMBERENC1:")) + assertFalse("plaintext app name must not survive in the encrypted file", encrypted.contains(marker)) + + // A fresh reader (simulating a restart) decrypts it back. + val reloaded = AccountStore(account.npub) + assertEquals(marker, reloaded.apps.value.first().app.name) + + // Locking evicts the key: nothing can be written to the database. + PassphraseLock.lock() + assertFalse(DesktopKeyStore.isMasterKeyAvailable()) + + // Unlock restores access; disabling the lock rewrites plaintext. + assertTrue(PassphraseLock.unlock("correct horse battery".toCharArray())) + assertEquals(marker, AmberDesktop.store(account.npub).apps.value.first().app.name) + + PassphraseLock.disable() + assertFalse("db should be plaintext after removing the passphrase", appsFile.readText().startsWith("AMBERENC1:")) + assertTrue(appsFile.readText().contains(marker)) + } +}