Encrypt the per-account database at rest when a passphrase is set

The passphrase lock protected the account keys but left the per-account
database — connected applications, permission grants, request history and
relay logs — as plaintext JSON, leaking metadata about which apps you sign
for. Those files are now encrypted with the master key (AES-256-GCM, an
"AMBERENC1:" header) whenever a passphrase lock is enabled.

- writeSecure/readSecure transparently encrypt/decrypt the apps, history and
  logs files; plaintext files from before the passphrase migrate on read.
- Enabling a passphrase re-encrypts every account's database immediately
  (rewriteAllStores); removing it rewrites plaintext. settings.json and
  accounts.json stay plaintext, but the private keys in accounts.json are
  already master-key-encrypted.
- DesktopKeyStore exposes synchronous encryptString/decryptString for the
  storage layer and refuses DB writes while locked rather than clobbering
  ciphertext with an empty document.

Adds a unit test covering the full round trip: plaintext → enable →
ciphertext (no plaintext leak) → reload decrypts → lock evicts the key →
unlock → disable → plaintext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i
This commit is contained in:
Claude
2026-10-05 13:50:48 -03:00
committed by greenart7c3
parent 6755b7a0e0
commit 5a86a22536
7 changed files with 189 additions and 30 deletions
+1 -1
View File
@@ -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
+7
View File
@@ -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
@@ -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<NormalizedRelayUrl> = settings.normalizedDefaultRelays()
@@ -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 }
@@ -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
}
@@ -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 <reified T> readJson(file: File): T? = try {
if (file.exists()) mapper.readValue<T>(file.readText()) else null
} catch (e: Exception) {
@@ -16,14 +30,41 @@ private inline fun <reified T> 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 <reified T> 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<T>(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<List<AppWithPermissions>>(appsFile) ?: emptyList())
val history = MutableStateFlow(readJson<List<HistoryRecord>>(historyFile) ?: emptyList())
val logs = MutableStateFlow(readJson<List<LogRecord>>(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<List<AppWithPermissions>>(appsFile) ?: emptyList())
val history = MutableStateFlow(readSecure<List<HistoryRecord>>(historyFile) ?: emptyList())
val logs = MutableStateFlow(readSecure<List<LogRecord>>(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() {
@@ -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))
}
}