Add an opt-in passphrase lock for the desktop signer

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i
This commit is contained in:
Claude
2026-09-28 10:12:10 -03:00
committed by greenart7c3
parent ca2812a7e7
commit e4256d0ebf
14 changed files with 741 additions and 24 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`); 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
+28
View File
@@ -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
+3
View File
@@ -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)
@@ -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()
}
}
@@ -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<NormalizedRelayUrl> = settings.normalizedDefaultRelays()
@@ -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<String>()
val indexEntries = mutableMapOf<String, Pair<String, String>>()
@@ -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<RequestedPermission> = 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)
@@ -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
}
@@ -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<NormalizedRelayUrl> = defaultRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
}
@@ -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<WrappedKeyBlob>(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)"
}
@@ -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,
@@ -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<PassphraseDialogMode?>(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,
@@ -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<String?>(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,
)
}
}
}
@@ -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))
}
}
+1
View File
@@ -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" }