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