mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 11:28:22 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+18
-13
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user