From 2411221688f4469ed69fbff31081a41f2e80ded6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 22:36:09 +0000 Subject: [PATCH] Add an opt-in passphrase lock for the desktop signer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides protection that does not rely on the OS credential store: when a passphrase is set, the AES master key is stored only wrapped (AES-256-GCM) under an Argon2id-derived key in master.key.enc, and the plain keystore and its credential-store/file password are deleted. The passphrase is never persisted. - PassphraseLock: enable/unlock/lock/disable/changePassphrase, with a DISABLED/LOCKED/UNLOCKED state flow the UI observes - Startup unlock screen; Settings → Security controls to set, change and remove the passphrase, choose an auto-lock timeout, and lock on demand - Locking evicts every decrypted key from memory, clears pending requests and disconnects the relays; the bunker engine refuses to consume or subscribe while locked, so nothing can be signed until unlock - Auto-lock after an idle timeout (off by default); activity resets it - Full-lifecycle unit test with reduced Argon2 cost parameters Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i --- CLAUDE.md | 2 +- desktop/README.md | 28 +++ desktop/build.gradle.kts | 3 + .../greenart7c3/nostrsigner/desktop/Main.kt | 33 ++- .../nostrsigner/desktop/core/AmberDesktop.kt | 5 + .../nostrsigner/desktop/core/BunkerEngine.kt | 10 +- .../desktop/core/DesktopKeyStore.kt | 100 ++++++-- .../nostrsigner/desktop/core/Models.kt | 2 + .../desktop/core/PassphraseLock.kt | 224 ++++++++++++++++++ .../greenart7c3/nostrsigner/desktop/ui/App.kt | 7 + .../nostrsigner/desktop/ui/SettingsScreen.kt | 182 +++++++++++++- .../nostrsigner/desktop/ui/UnlockScreen.kt | 85 +++++++ .../nostrsigner/desktop/PassphraseLockTest.kt | 83 +++++++ gradle/libs.versions.toml | 1 + 14 files changed, 741 insertions(+), 24 deletions(-) create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/PassphraseLock.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/PassphraseLockTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index c98e6931..aa4adce3 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`); 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. State is JSON files per account (no Room). See `desktop/README.md`. ## Architecture diff --git a/desktop/README.md b/desktop/README.md index e6922760..34397c7c 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -56,6 +56,34 @@ protection too. If you move the data directory to another machine, also transfer the `com.greenart7c3.nostrsigner` entry from the credential store (or keep the legacy `keystore.pass` file). +### Passphrase lock (stronger, opt-in) + +Enable a passphrase under **Settings → Security** for defence that does not +depend on the OS credential store. The AES master key is then stored only +wrapped (AES-256-GCM) under a key derived from your passphrase with +**Argon2id**, in `master.key.enc`; the plain keystore and its +credential-store/file password are deleted. The passphrase is never written +anywhere. + +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. +- 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 + no request can be signed until you unlock again. + +Residual risk it cannot remove: while unlocked, the keys are in the +process's memory, so malware that can scrape another process's memory or +log your keystrokes in your session could still capture them — that is +inherent to any software signer on a general-purpose OS. Hardware-backed, +per-signature consent (Touch ID / Windows Hello / TPM) would be the next +step and is tracked as future work. + +**If you forget the passphrase there is no recovery** — restore your keys +from their nsec or seed-word backup instead. + ## Run and build ```bash diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 6288a7c1..2dec8e3e 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { // freedesktop Secret Service) for the keystore password. implementation(libs.java.keyring) runtimeOnly(libs.slf4j.nop) + + // Argon2id for the optional passphrase lock. + implementation(libs.bouncycastle) implementation(libs.okhttp) implementation(libs.kotlinx.collections.immutable) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index 5b7800ea..d2eacb07 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -12,6 +12,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AccountManager import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.SettingsStore import com.greenart7c3.nostrsigner.desktop.ui.App import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme @@ -26,13 +27,33 @@ object Session { fun boot() { AmberDesktop.applicationIOScope.launch { - val saved = AmberDesktop.settings.currentAccount - val npub = saved.ifBlank { AccountsStore.accounts.value.firstOrNull()?.npub ?: "" } - if (npub.isNotBlank()) { - account.value = AmberDesktop.account(npub) + var engineStarted = false + PassphraseLock.state.collect { status -> + when (status) { + PassphraseLock.Status.LOCKED -> { + // Key material is evicted; drop the account reference so + // nothing in the UI can reach a decrypted signer. + account.value = null + loading.value = false + } + + PassphraseLock.Status.DISABLED, PassphraseLock.Status.UNLOCKED -> { + val saved = AmberDesktop.settings.currentAccount + val npub = saved.ifBlank { AccountsStore.accounts.value.firstOrNull()?.npub ?: "" } + if (npub.isNotBlank()) { + account.value = AmberDesktop.account(npub) + } + loading.value = false + if (engineStarted) { + AmberDesktop.engine.updateFilter() + AmberDesktop.client.connect() + } else { + AmberDesktop.engine.start() + engineStarted = true + } + } + } } - loading.value = false - AmberDesktop.engine.start() } } 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 6665a11c..fa9fdc58 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 @@ -73,6 +73,11 @@ object AmberDesktop { stores.remove(npub) } + /** Drops every decrypted account key from memory (passphrase lock). */ + fun evictAllAccounts() { + accountCache.clear() + } + val settings: DesktopSettings get() = SettingsStore.settings.value fun defaultRelays(): List = settings.normalizedDefaultRelays() diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt index 3131ad66..dbb76f5f 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt @@ -112,7 +112,12 @@ class BunkerEngine( } /** Mirrors `NotificationSubscription.updateFilter`. */ - suspend fun updateFilter() = filterMutex.withLock { + suspend fun updateFilter() { + if (PassphraseLock.isLocked()) return + updateFilterLocked() + } + + private suspend fun updateFilterLocked() = filterMutex.withLock { val activeSubKeys = mutableSetOf() val indexEntries = mutableMapOf>() @@ -192,6 +197,7 @@ class BunkerEngine( /** Mirrors `EventNotificationConsumer.consume` + `notify`. */ suspend fun consume(event: Event, relay: NormalizedRelayUrl) { + if (PassphraseLock.isLocked()) return if (event.kind != NostrConnectEvent.KIND) return if (!event.verify()) return if (event.content.isEmpty()) return @@ -547,6 +553,7 @@ class BunkerEngine( grantedPermissions: List = emptyList(), signPolicy: Int? = null, ) { + PassphraseLock.touch() removePending(req.request.id) val acc = req.account val store = AmberDesktop.store(acc.npub) @@ -640,6 +647,7 @@ class BunkerEngine( req: PendingBunkerRequest, rememberType: RememberType, ) { + PassphraseLock.touch() removePending(req.request.id) val acc = req.account val store = AmberDesktop.store(acc.npub) 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 235a4d06..41158b15 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 @@ -13,12 +13,16 @@ import kotlinx.coroutines.sync.withLock /** * Desktop counterpart of the Android `SecureCryptoHelper`: private keys are - * encrypted at rest with an AES-256 key held in a Java KeyStore (PKCS12) - * file. The keystore password lives in the OS credential store (macOS - * Keychain, Windows Credential Manager, or the freedesktop Secret Service) - * when one is available, so the data directory alone is not enough to - * unlock the keys; systems without a secret daemon fall back to an - * owner-only password file next to the keystore (see [KeystorePassword]). + * encrypted at rest with an AES-256 master key. + * + * Where that master key lives depends on the security mode: + * - Default: in a Java KeyStore (PKCS12) file whose password is kept in the + * OS credential store (macOS Keychain, Windows Credential Manager, + * freedesktop Secret Service) when available, falling back to an + * owner-only password file (see [KeystorePassword]). + * - Passphrase lock enabled ([PassphraseLock]): the master key exists on + * disk only wrapped under a key derived from the user's passphrase; it is + * installed here at unlock time and evicted on lock. */ object DesktopKeyStore { private const val KEY_ALIAS = "AMBER_AES_KEY" @@ -31,11 +35,13 @@ object DesktopKeyStore { private val passwordFile: File get() = File(AppDirs.dataDir, "keystore.pass") private var cachedKey: SecretKey? = null - private var cachedSource: PasswordStore? = null + private var sourceDescription: String? = null - /** Where the keystore password is kept, for display in Settings. */ + /** Where the master key/its password is kept, for display in Settings. */ val passwordSourceDescription: String? - get() = cachedSource?.description + get() = sourceDescription + + class LockedException : IllegalStateException("The key store is locked. Unlock it with your passphrase first.") suspend fun encrypt(plainText: String): String = mutex.withLock { val key = getOrCreateSecretKey() @@ -69,6 +75,58 @@ object DesktopKeyStore { return String(plainBytes, Charsets.UTF_8) } + // ----- master key management (used by PassphraseLock) ----- + + /** Installs an unwrapped master key (unlock, or right after enabling the lock). */ + internal fun installMasterKey(key: SecretKey, source: String) { + cachedKey = key + sourceDescription = source + } + + /** Evicts the in-memory master key (lock). */ + internal fun clearMasterKey() { + cachedKey = null + sourceDescription = null + } + + /** + * Returns the master key for wrapping under a passphrase, creating it + * through the regular keystore path when it does not exist yet. Must only + * be called while the passphrase lock is disabled or unlocked. + */ + internal suspend fun masterKeyForWrapping(): SecretKey = mutex.withLock { + cachedKey ?: loadOrCreateFromKeystore() + } + + /** + * Deletes the unprotected key copies (PKCS12 keystore + its password in + * the OS credential store / password file) after the passphrase lock has + * taken ownership of the master key. + */ + internal fun removeUnprotectedCopies() { + keyStoreFile.delete() + FilePasswordStore(passwordFile).delete() + OsCredentialStore().delete() + } + + /** + * Re-creates the unprotected storage (keystore + credential-store/file + * password) from [key] when the passphrase lock is removed. + */ + internal suspend fun recreateUnprotectedStore(key: SecretKey): Unit = mutex.withLock { + val resolved = KeystorePassword.resolve( + osStore = OsCredentialStore(), + fileStore = FilePasswordStore(passwordFile), + keystoreExists = false, + opens = { false }, + ) + writeKeystore(key, resolved.password.toCharArray()) + cachedKey = key + sourceDescription = resolved.source.description + } + + // ----- keystore-backed path (passphrase lock disabled) ----- + private fun loadKeyStore(password: CharArray): KeyStore { val keyStore = KeyStore.getInstance("PKCS12") keyStoreFile.inputStream().use { keyStore.load(it, password) } @@ -82,8 +140,24 @@ object DesktopKeyStore { false } + private fun writeKeystore(key: SecretKey, password: CharArray) { + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(null, password) + keyStore.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(key), KeyStore.PasswordProtection(password)) + keyStoreFile.outputStream().use { keyStore.store(it, password) } + AppDirs.restrictToOwner(keyStoreFile) + } + private fun getOrCreateSecretKey(): SecretKey { cachedKey?.let { return it } + if (PassphraseLock.isEnabled()) { + throw LockedException() + } + return loadOrCreateFromKeystore() + } + + private fun loadOrCreateFromKeystore(): SecretKey { + cachedKey?.let { return it } val resolved = KeystorePassword.resolve( osStore = OsCredentialStore(), @@ -91,7 +165,7 @@ object DesktopKeyStore { keystoreExists = keyStoreFile.exists(), opens = ::canOpen, ) - cachedSource = resolved.source + sourceDescription = resolved.source.description AmberLogger.d("DesktopKeyStore", "Keystore password source: ${resolved.source.description}") val password = resolved.password.toCharArray() @@ -112,12 +186,8 @@ object DesktopKeyStore { return key } - val keyStore = KeyStore.getInstance("PKCS12") - keyStore.load(null, password) val key = generateKey() - keyStore.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(key), KeyStore.PasswordProtection(password)) - keyStoreFile.outputStream().use { keyStore.store(it, password) } - AppDirs.restrictToOwner(keyStoreFile) + writeKeystore(key, password) cachedKey = key return key } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt index f0c621bf..5141bd83 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt @@ -158,6 +158,8 @@ data class DesktopSettings( ), val currentAccount: String = "", val darkTheme: Boolean? = null, + /** Auto-lock delay for the passphrase lock, in minutes; 0 = never. */ + val autoLockMinutes: Int = 0, ) { fun normalizedDefaultRelays(): List = defaultRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(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 new file mode 100644 index 00000000..1151525f --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/PassphraseLock.kt @@ -0,0 +1,224 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import java.io.File +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters + +/** + * Optional passphrase lock: when enabled, the master AES key exists on disk + * only wrapped (AES-256-GCM) under a key derived from the user's passphrase + * with Argon2id. The passphrase itself is never stored anywhere — neither + * the data directory nor the OS credential store is enough to decrypt the + * account keys, which is the strongest protection a portable desktop app + * can offer against same-user malware reading files at rest. + * + * While unlocked, the unwrapped master key (and the decrypted account keys) + * live in this process's memory; locking evicts them and disconnects the + * relay client. + */ +object PassphraseLock { + enum class Status { + /** No passphrase configured; the keystore + credential store path is used. */ + DISABLED, + + /** Passphrase configured, master key not in memory. Nothing can be signed. */ + LOCKED, + + /** Passphrase configured and entered; master key available until lock(). */ + UNLOCKED, + } + + val state = MutableStateFlow(if (isEnabled()) Status.LOCKED else Status.DISABLED) + + /** Argon2id cost parameters, persisted per blob so they can evolve safely. */ + data class KdfParams( + val memoryKb: Int = 65536, // 64 MB + val iterations: Int = 3, + val parallelism: Int = 2, + ) + + private data class WrappedKeyBlob( + val version: Int = 1, + val salt: String, + val memoryKb: Int, + val iterations: Int, + val parallelism: Int, + val iv: String, + val cipherText: String, + ) + + private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + private val mutex = Mutex() + private var autoLockJob: Job? = null + + private val blobFile: File get() = File(AppDirs.dataDir, "master.key.enc") + + fun isEnabled(): Boolean = blobFile.exists() + + fun isLocked(): Boolean = state.value == Status.LOCKED + + /** + * Turns the lock on: wraps the current master key (creating one if this + * is a fresh install) under the passphrase and deletes every unprotected + * copy. The app stays unlocked afterwards. + */ + suspend fun enable(passphrase: CharArray, params: KdfParams = KdfParams()) = mutex.withLock { + check(!isEnabled()) { "A passphrase is already set" } + val key = DesktopKeyStore.masterKeyForWrapping() + writeBlob(key, passphrase, params) + DesktopKeyStore.removeUnprotectedCopies() + DesktopKeyStore.installMasterKey(key, SOURCE_DESCRIPTION) + state.value = Status.UNLOCKED + scheduleAutoLock() + } + + /** Verifies the passphrase and installs the master key. */ + suspend fun unlock(passphrase: CharArray): Boolean = mutex.withLock { + if (!isEnabled()) return true + val key = unwrap(passphrase) ?: return false + DesktopKeyStore.installMasterKey(key, SOURCE_DESCRIPTION) + state.value = Status.UNLOCKED + scheduleAutoLock() + return true + } + + /** + * Evicts all key material from memory and disconnects from the relays. + * Incoming NIP-46 requests cannot be decrypted (let alone signed) until + * the next unlock. + */ + fun lock() { + if (!isEnabled()) return + autoLockJob?.cancel() + DesktopKeyStore.clearMasterKey() + AmberDesktop.evictAllAccounts() + AmberDesktop.engine.pending.value = emptyList() + AmberDesktop.client.disconnect() + state.value = Status.LOCKED + } + + /** Removes the lock, restoring the keystore + credential-store storage. */ + suspend fun disable() = mutex.withLock { + check(state.value == Status.UNLOCKED) { "Unlock first" } + val key = DesktopKeyStore.masterKeyForWrapping() + DesktopKeyStore.recreateUnprotectedStore(key) + blobFile.delete() + autoLockJob?.cancel() + state.value = Status.DISABLED + } + + /** Rewraps the master key under a new passphrase; false when [old] is wrong. */ + suspend fun changePassphrase(old: CharArray, new: CharArray, params: KdfParams = KdfParams()): Boolean = mutex.withLock { + val key = unwrap(old) ?: return false + writeBlob(key, new, params) + DesktopKeyStore.installMasterKey(key, SOURCE_DESCRIPTION) + state.value = Status.UNLOCKED + scheduleAutoLock() + return true + } + + /** Restarts the auto-lock countdown; call on user/signing activity. */ + fun touch() { + if (state.value == Status.UNLOCKED) scheduleAutoLock() + } + + private fun scheduleAutoLock() { + autoLockJob?.cancel() + val minutes = SettingsStore.settings.value.autoLockMinutes + if (minutes <= 0) return + autoLockJob = AmberDesktop.applicationIOScope.launch { + delay(minutes * 60_000L) + AmberLogger.d("PassphraseLock", "Auto-locking after $minutes minutes") + lock() + } + } + + // ----- wrapping crypto ----- + + private fun deriveKey(passphrase: CharArray, salt: ByteArray, params: KdfParams): ByteArray { + val generator = Argon2BytesGenerator() + generator.init( + Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withSalt(salt) + .withMemoryAsKB(params.memoryKb) + .withIterations(params.iterations) + .withParallelism(params.parallelism) + .build(), + ) + val out = ByteArray(32) + val passphraseBytes = String(passphrase).toByteArray(Charsets.UTF_8) + try { + generator.generateBytes(passphraseBytes, out) + } finally { + passphraseBytes.fill(0) + } + return out + } + + private fun writeBlob(key: SecretKey, passphrase: CharArray, params: KdfParams) { + val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } + val derived = deriveKey(passphrase, salt, params) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(derived, "AES")) + val cipherText = cipher.doFinal(key.encoded) + derived.fill(0) + + val encoder = Base64.getEncoder().withoutPadding() + val blob = WrappedKeyBlob( + salt = encoder.encodeToString(salt), + memoryKb = params.memoryKb, + iterations = params.iterations, + parallelism = params.parallelism, + iv = encoder.encodeToString(cipher.iv), + cipherText = encoder.encodeToString(cipherText), + ) + val tmp = File(blobFile.parentFile, "${blobFile.name}.tmp") + tmp.writeText(mapper.writeValueAsString(blob)) + if (!tmp.renameTo(blobFile)) { + blobFile.writeText(tmp.readText()) + tmp.delete() + } + AppDirs.restrictToOwner(blobFile) + } + + private fun unwrap(passphrase: CharArray): SecretKey? { + val blob = try { + mapper.readValue(blobFile.readText()) + } catch (e: Exception) { + AmberLogger.e("PassphraseLock", "Corrupt wrapped-key blob", e) + return null + } + val decoder = Base64.getDecoder() + val derived = deriveKey( + passphrase, + decoder.decode(blob.salt), + KdfParams(blob.memoryKb, blob.iterations, blob.parallelism), + ) + return try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(derived, "AES"), GCMParameterSpec(128, decoder.decode(blob.iv))) + SecretKeySpec(cipher.doFinal(decoder.decode(blob.cipherText)), "AES") + } catch (_: Exception) { + null // wrong passphrase (GCM tag mismatch) + } finally { + derived.fill(0) + } + } + + private const val SOURCE_DESCRIPTION = "your passphrase (Argon2id, never stored)" +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt index 62c1ee67..870aa831 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.Session import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.toShortenHex sealed class Route(val title: String, val icon: ImageVector) { @@ -79,6 +80,12 @@ fun App() { return } + val lockStatus by PassphraseLock.state.collectAsState() + if (lockStatus == PassphraseLock.Status.LOCKED) { + UnlockScreen() + return + } + if (acc == null || addingAccount) { LoginScreen( hasAccounts = acc != null, diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt index feb3681d..3b9f4268 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt @@ -38,6 +38,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore +import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.SettingsStore import com.greenart7c3.nostrsigner.desktop.core.toShortenHex import java.text.DateFormat @@ -149,10 +150,12 @@ fun SettingsScreen(account: DesktopAccount) { Spacer(Modifier.height(16.dp)) SectionTitle("Security") Text( - "Keys are encrypted with AES-256; the keystore password is kept in: " + + "Keys are encrypted with AES-256; the encryption key is protected by: " + (DesktopKeyStore.passwordSourceDescription ?: "…"), style = MaterialTheme.typography.bodySmall, ) + Spacer(Modifier.height(8.dp)) + SecuritySection() Spacer(Modifier.height(16.dp)) SectionTitle("Diagnostics") @@ -192,6 +195,183 @@ private fun SectionTitle(text: String) { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } +@Composable +private fun SecuritySection() { + val scope = rememberCoroutineScope() + val lockStatus by PassphraseLock.state.collectAsState() + val settings by SettingsStore.settings.collectAsState() + var dialog by remember { mutableStateOf(null) } + var showRemoveConfirm by remember { mutableStateOf(false) } + + if (lockStatus == PassphraseLock.Status.DISABLED) { + Text( + "Set a passphrase to keep your keys encrypted even against software " + + "that can read your files. You will be asked for it when Amber starts.", + style = MaterialTheme.typography.bodySmall, + ) + AmberButton(text = "Set a passphrase", onClick = { dialog = PassphraseDialogMode.SET }) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AmberOutlinedButton( + modifier = Modifier.weight(1f), + text = "Lock now", + onClick = { PassphraseLock.lock() }, + ) + AmberOutlinedButton( + modifier = Modifier.weight(1f), + text = "Change passphrase", + onClick = { dialog = PassphraseDialogMode.CHANGE }, + ) + AmberOutlinedButton( + modifier = Modifier.weight(1f), + text = "Remove passphrase", + onClick = { showRemoveConfirm = true }, + ) + } + Spacer(Modifier.height(8.dp)) + Text("Lock automatically after", style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(0 to "Never", 5 to "5 min", 15 to "15 min", 60 to "1 hour").forEach { (minutes, label) -> + FilterChip( + selected = settings.autoLockMinutes == minutes, + onClick = { + SettingsStore.update { it.copy(autoLockMinutes = minutes) } + PassphraseLock.touch() + }, + label = { Text(label) }, + ) + } + } + } + + dialog?.let { mode -> + PassphraseDialog(mode) { dialog = null } + } + if (showRemoveConfirm) { + AlertDialog( + onDismissRequest = { showRemoveConfirm = false }, + title = { Text("Remove the passphrase?") }, + text = { + Text( + "The encryption key will go back to the OS credential store " + + "(or a local file), and Amber will no longer ask for a " + + "passphrase at startup.", + ) + }, + confirmButton = { + TextButton( + onClick = { + showRemoveConfirm = false + scope.launch { + PassphraseLock.disable() + Toaster.toast("Passphrase removed") + } + }, + ) { Text("Remove") } + }, + dismissButton = { + TextButton(onClick = { showRemoveConfirm = false }) { Text("Cancel") } + }, + ) + } +} + +private enum class PassphraseDialogMode { SET, CHANGE } + +@Composable +private fun PassphraseDialog( + mode: PassphraseDialogMode, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var current by remember { mutableStateOf("") } + var new by remember { mutableStateOf("") } + var confirm by remember { mutableStateOf("") } + var working by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { if (!working) onDismiss() }, + title = { Text(if (mode == PassphraseDialogMode.SET) "Set a passphrase" else "Change the passphrase") }, + text = { + Column { + Text( + "The passphrase is never stored anywhere. If you forget it, the " + + "only way back in is restoring your keys from a backup (nsec " + + "or seed words).", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + if (mode == PassphraseDialogMode.CHANGE) { + OutlinedTextField( + value = current, + onValueChange = { current = it }, + label = { Text("Current passphrase") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + } + OutlinedTextField( + value = new, + onValueChange = { new = it }, + label = { Text("New passphrase (min. 8 characters)") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = confirm, + onValueChange = { confirm = it }, + label = { Text("Repeat the new passphrase") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + enabled = !working, + onClick = { + if (new.length < 8) { + Toaster.toast("Use at least 8 characters") + return@TextButton + } + if (new != confirm) { + Toaster.toast("The passphrases do not match") + return@TextButton + } + working = true + scope.launch { + try { + if (mode == PassphraseDialogMode.SET) { + PassphraseLock.enable(new.toCharArray()) + Toaster.toast("Passphrase set") + onDismiss() + } else { + if (PassphraseLock.changePassphrase(current.toCharArray(), new.toCharArray())) { + Toaster.toast("Passphrase changed") + onDismiss() + } else { + Toaster.toast("Wrong current passphrase") + } + } + } catch (e: Exception) { + Toaster.toast(e.message ?: "Failed to update the passphrase") + } + working = false + } + }, + ) { Text(if (working) "Working…" else "Save") } + }, + dismissButton = { + TextButton(enabled = !working, onClick = onDismiss) { Text("Cancel") } + }, + ) +} + @Composable private fun BackupDialog( account: DesktopAccount, diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt new file mode 100644 index 00000000..8f03aa48 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UnlockScreen.kt @@ -0,0 +1,85 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock +import kotlinx.coroutines.launch + +@Composable +fun UnlockScreen() { + val scope = rememberCoroutineScope() + var passphrase by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var working by remember { mutableStateOf(false) } + + fun submit() { + if (passphrase.isEmpty() || working) return + working = true + error = null + scope.launch { + val ok = PassphraseLock.unlock(passphrase.toCharArray()) + if (!ok) { + error = "Wrong passphrase" + } else { + passphrase = "" + } + working = false + } + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 480.dp).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Amber", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) + Text("Locked", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = passphrase, + onValueChange = { passphrase = it }, + label = { Text("Passphrase") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(16.dp)) + AmberButton( + text = if (working) "Unlocking…" else "Unlock", + enabled = passphrase.isNotEmpty() && !working, + onClick = ::submit, + ) + } + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/PassphraseLockTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/PassphraseLockTest.kt new file mode 100644 index 00000000..907a3f3d --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/PassphraseLockTest.kt @@ -0,0 +1,83 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore +import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test + +/** + * Exercises the full passphrase-lock lifecycle against a scratch data dir. + * Small Argon2 parameters keep the tests fast; production defaults are only + * a cost change, not a code path change. + */ +class PassphraseLockTest { + companion object { + private val fastKdf = PassphraseLock.KdfParams(memoryKb = 1024, iterations = 1, parallelism = 1) + + @JvmStatic + @BeforeClass + fun isolateDataDir() { + val tmp = File.createTempFile("amber-lock-test", "").apply { + delete() + mkdirs() + deleteOnExit() + } + System.setProperty("user.home", tmp.absolutePath) + } + } + + @Test + fun fullLifecycle() = runBlocking { + // Plain mode: encrypt something so the master key exists. + val secret = "super-secret-private-key" + val cipher1 = DesktopKeyStore.encrypt(secret) + assertEquals(secret, DesktopKeyStore.decrypt(cipher1)) + assertEquals(PassphraseLock.Status.DISABLED, PassphraseLock.state.value) + + // Enable the lock: same master key, so old ciphertexts still decrypt. + PassphraseLock.enable("correct horse battery staple".toCharArray(), fastKdf) + assertTrue(PassphraseLock.isEnabled()) + assertEquals(PassphraseLock.Status.UNLOCKED, PassphraseLock.state.value) + assertEquals(secret, DesktopKeyStore.decrypt(cipher1)) + + // The unprotected copies are gone. + val dataDir = com.greenart7c3.nostrsigner.desktop.core.AppDirs.dataDir + assertFalse(File(dataDir, "amber.keystore").exists()) + assertFalse(File(dataDir, "keystore.pass").exists()) + assertTrue(File(dataDir, "master.key.enc").exists()) + + // Locking evicts the key: crypto operations fail. + PassphraseLock.lock() + assertEquals(PassphraseLock.Status.LOCKED, PassphraseLock.state.value) + assertThrows(DesktopKeyStore.LockedException::class.java) { + runBlocking { DesktopKeyStore.decrypt(cipher1) } + } + + // Wrong passphrase is rejected, right one restores access. + assertFalse(PassphraseLock.unlock("wrong passphrase".toCharArray())) + assertEquals(PassphraseLock.Status.LOCKED, PassphraseLock.state.value) + assertTrue(PassphraseLock.unlock("correct horse battery staple".toCharArray())) + assertEquals(secret, DesktopKeyStore.decrypt(cipher1)) + + // Changing the passphrase requires the old one and keeps the data. + assertFalse(PassphraseLock.changePassphrase("nope".toCharArray(), "new passphrase 42".toCharArray(), fastKdf)) + assertTrue(PassphraseLock.changePassphrase("correct horse battery staple".toCharArray(), "new passphrase 42".toCharArray(), fastKdf)) + PassphraseLock.lock() + assertFalse(PassphraseLock.unlock("correct horse battery staple".toCharArray())) + assertTrue(PassphraseLock.unlock("new passphrase 42".toCharArray())) + assertEquals(secret, DesktopKeyStore.decrypt(cipher1)) + + // Disabling restores the keystore + password-store path. + PassphraseLock.disable() + assertEquals(PassphraseLock.Status.DISABLED, PassphraseLock.state.value) + assertFalse(File(dataDir, "master.key.enc").exists()) + assertTrue(File(dataDir, "amber.keystore").exists()) + assertEquals(secret, DesktopKeyStore.decrypt(cipher1)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b224cdc2..2b1c1513 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,6 +91,7 @@ secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", versi leakcanary = { group = "com.android.tools.studio.leakcanary", name = "leakcanary", version.ref = "leakcanary" } java-keyring = { module = "com.github.javakeyring:java-keyring", version = "1.0.4" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version = "2.0.7" } +bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.84" } [plugins] androidLibrary = { id = "com.android.library", version.ref = "agp" }