Keep the desktop keystore password in the OS credential store

The PKCS12 keystore password now lives in the platform's native secret
storage — macOS Keychain, Windows Credential Manager, or the freedesktop
Secret Service (GNOME Keyring / KWallet) — via the java-keyring library,
so a copied or stolen data directory is no longer enough to decrypt the
account keys.

- KeystorePassword.resolve prefers the OS store, migrates an existing
  keystore.pass file into it on first run, and repairs stale
  credential-store entries from a working file copy
- Systems without a secret daemon (headless Linux, minimal WMs, CI) fall
  back to the previous owner-only password file
- The Settings screen shows which backend holds the password
- Unit tests cover resolution, migration, fallback and failure paths

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 5af0afdd45
commit ca2812a7e7
8 changed files with 351 additions and 26 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`); 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`); state is JSON files per account (no Room). See `desktop/README.md`.
## Architecture
+20 -5
View File
@@ -35,11 +35,26 @@ a Java KeyStore (PKCS12) file under the application data directory:
- macOS: `~/Library/Application Support/Amber`
- Linux: `$XDG_DATA_HOME/amber` (or `~/.local/share/amber`)
Desktop platforms have no universal hardware-backed keystore, so the
keystore password is a per-install random secret stored next to the
keystore with owner-only permissions. Anyone with access to your OS user
account can read your keys — use full-disk encryption and OS login
protection.
The keystore password is kept in the operating system's credential store:
- macOS: Keychain
- Windows: Credential Manager
- Linux: the freedesktop Secret Service (GNOME Keyring / KWallet over D-Bus)
so copying the data directory (or a backup of it) is not enough to unlock
the keys. On systems without a secret daemon (headless Linux, minimal
window managers) the password falls back to an owner-only file next to the
keystore, and is migrated into the credential store automatically the
first time one becomes available. The Settings screen shows which backend
is in use.
Note the trust model: any process running as your OS user can request the
secret from the credential store, so this protects against offline attacks
(disk theft, leaked backups, copied data directories) rather than against
malware running in your session. Use full-disk encryption and OS login
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).
## Run and build
+5
View File
@@ -19,6 +19,11 @@ dependencies {
implementation(libs.quartz.jvm)
// Native secp256k1 bindings for the JVM (Schnorr signatures + ECDH).
implementation(libs.secp256k1.jni.jvm)
// OS credential stores (macOS Keychain, Windows Credential Manager,
// freedesktop Secret Service) for the keystore password.
implementation(libs.java.keyring)
runtimeOnly(libs.slf4j.nop)
implementation(libs.okhttp)
implementation(libs.kotlinx.collections.immutable)
@@ -3,7 +3,6 @@ package com.greenart7c3.nostrsigner.desktop.core
import java.io.File
import java.nio.ByteBuffer
import java.security.KeyStore
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -15,9 +14,11 @@ 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. There is no OS-backed hardware keystore available across all three
* desktop platforms, so the keystore password is a per-install random secret
* stored next to the keystore with owner-only file permissions.
* 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]).
*/
object DesktopKeyStore {
private const val KEY_ALIAS = "AMBER_AES_KEY"
@@ -30,6 +31,11 @@ object DesktopKeyStore {
private val passwordFile: File get() = File(AppDirs.dataDir, "keystore.pass")
private var cachedKey: SecretKey? = null
private var cachedSource: PasswordStore? = null
/** Where the keystore password is kept, for display in Settings. */
val passwordSourceDescription: String?
get() = cachedSource?.description
suspend fun encrypt(plainText: String): String = mutex.withLock {
val key = getOrCreateSecretKey()
@@ -63,39 +69,62 @@ object DesktopKeyStore {
return String(plainBytes, Charsets.UTF_8)
}
private fun keystorePassword(): CharArray {
if (!passwordFile.exists()) {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
passwordFile.writeText(Base64.getEncoder().withoutPadding().encodeToString(bytes))
AppDirs.restrictToOwner(passwordFile)
}
return passwordFile.readText().trim().toCharArray()
private fun loadKeyStore(password: CharArray): KeyStore {
val keyStore = KeyStore.getInstance("PKCS12")
keyStoreFile.inputStream().use { keyStore.load(it, password) }
return keyStore
}
private fun canOpen(password: String): Boolean = try {
loadKeyStore(password.toCharArray())
true
} catch (_: Exception) {
false
}
private fun getOrCreateSecretKey(): SecretKey {
cachedKey?.let { return it }
val password = keystorePassword()
val keyStore = KeyStore.getInstance("PKCS12")
val resolved = KeystorePassword.resolve(
osStore = OsCredentialStore(),
fileStore = FilePasswordStore(passwordFile),
keystoreExists = keyStoreFile.exists(),
opens = ::canOpen,
)
cachedSource = resolved.source
AmberLogger.d("DesktopKeyStore", "Keystore password source: ${resolved.source.description}")
val password = resolved.password.toCharArray()
if (keyStoreFile.exists()) {
keyStoreFile.inputStream().use { keyStore.load(it, password) }
val keyStore = loadKeyStore(password)
val entry = keyStore.getEntry(KEY_ALIAS, KeyStore.PasswordProtection(password)) as? KeyStore.SecretKeyEntry
if (entry != null) {
cachedKey = entry.secretKey
return entry.secretKey
}
} else {
keyStore.load(null, password)
// Keystore exists but the entry is missing (should not happen);
// fall through and add a fresh key to the same store.
val key = generateKey()
keyStore.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(key), KeyStore.PasswordProtection(password))
keyStoreFile.outputStream().use { keyStore.store(it, password) }
AppDirs.restrictToOwner(keyStoreFile)
cachedKey = key
return key
}
val keyGenerator = KeyGenerator.getInstance("AES")
keyGenerator.init(256)
val key = keyGenerator.generateKey()
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)
cachedKey = key
return key
}
private fun generateKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance("AES")
keyGenerator.init(256)
return keyGenerator.generateKey()
}
}
@@ -0,0 +1,157 @@
package com.greenart7c3.nostrsigner.desktop.core
import com.github.javakeyring.Keyring
import com.github.javakeyring.PasswordAccessException
import java.io.File
import java.security.SecureRandom
import java.util.Base64
/** Where the keystore password can be kept. */
interface PasswordStore {
/** Shown in the Settings screen so users know where their secret lives. */
val description: String
/** Returns the stored password, or null when absent/unavailable. */
fun load(): String?
/** Persists the password; returns false when the backend is unavailable. */
fun store(secret: String): Boolean
fun delete()
}
/**
* OS-native credential storage: macOS Keychain, Windows Credential Manager,
* or the freedesktop Secret Service (GNOME Keyring / KWallet) on Linux.
* Unavailable backends (e.g. Linux without a D-Bus secret daemon) surface as
* load() == null / store() == false so callers fall back to the file store.
*/
class OsCredentialStore(
private val service: String = SERVICE,
private val account: String = ACCOUNT,
) : PasswordStore {
private val keyring: Keyring? by lazy {
try {
Keyring.create()
} catch (e: Throwable) {
AmberLogger.d("OsCredentialStore", "No OS credential store available: ${e.message}")
null
}
}
override val description: String = when {
System.getProperty("os.name").lowercase().contains("mac") -> "macOS Keychain"
System.getProperty("os.name").lowercase().contains("win") -> "Windows Credential Manager"
else -> "Secret Service (GNOME Keyring / KWallet)"
}
override fun load(): String? = try {
keyring?.getPassword(service, account)
} catch (e: PasswordAccessException) {
// Thrown both for "no entry" and "backend broken"; either way there is
// nothing usable here.
null
} catch (e: Throwable) {
AmberLogger.e("OsCredentialStore", "Failed to read from the credential store", e)
null
}
override fun store(secret: String): Boolean = try {
keyring?.setPassword(service, account, secret) != null
} catch (e: Throwable) {
AmberLogger.e("OsCredentialStore", "Failed to write to the credential store", e)
false
}
override fun delete() {
try {
keyring?.deletePassword(service, account)
} catch (_: Throwable) {
}
}
companion object {
const val SERVICE = "com.greenart7c3.nostrsigner"
const val ACCOUNT = "keystore-password"
}
}
/** Legacy/fallback storage: an owner-only file next to the keystore. */
class FilePasswordStore(private val file: File) : PasswordStore {
override val description: String = "local file (${file.name})"
override fun load(): String? = if (file.exists()) file.readText().trim().ifBlank { null } else null
override fun store(secret: String): Boolean {
file.writeText(secret)
AppDirs.restrictToOwner(file)
return true
}
override fun delete() {
file.delete()
}
}
/**
* Resolves the keystore password, preferring the OS credential store and
* migrating any legacy password file into it. The file remains the fallback
* on systems without a secret daemon.
*/
object KeystorePassword {
data class Resolved(val password: String, val source: PasswordStore)
fun generate(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.getEncoder().withoutPadding().encodeToString(bytes)
}
/**
* @param keystoreExists whether an encrypted keystore already exists on disk
* @param opens returns true when the candidate password unlocks that keystore
*/
fun resolve(
osStore: PasswordStore,
fileStore: PasswordStore,
keystoreExists: Boolean,
opens: (String) -> Boolean,
): Resolved {
if (!keystoreExists) {
val fresh = generate()
if (osStore.store(fresh)) {
// Never leave a stale file copy behind once the OS store owns it.
fileStore.delete()
return Resolved(fresh, osStore)
}
fileStore.store(fresh)
return Resolved(fresh, fileStore)
}
val osValue = osStore.load()
if (osValue != null && opens(osValue)) {
// The OS store is authoritative; drop any file copy so the data
// directory alone is no longer enough to unlock the keys.
fileStore.delete()
return Resolved(osValue, osStore)
}
val fileValue = fileStore.load()
if (fileValue != null && opens(fileValue)) {
// Migrate the legacy file into the OS store when possible.
return if (osStore.store(fileValue)) {
fileStore.delete()
Resolved(fileValue, osStore)
} else {
Resolved(fileValue, fileStore)
}
}
throw IllegalStateException(
"Unable to unlock the key store: no working password in the " +
"OS credential store or the password file. If you copied the " +
"data directory from another machine, also transfer the " +
"'${OsCredentialStore.SERVICE}' entry from its credential store.",
)
}
}
@@ -37,6 +37,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.DesktopKeyStore
import com.greenart7c3.nostrsigner.desktop.core.SettingsStore
import com.greenart7c3.nostrsigner.desktop.core.toShortenHex
import java.text.DateFormat
@@ -145,6 +146,14 @@ fun SettingsScreen(account: DesktopAccount) {
}
AmberOutlinedButton(text = "Add an account", onClick = { Session.addingAccount.value = true })
Spacer(Modifier.height(16.dp))
SectionTitle("Security")
Text(
"Keys are encrypted with AES-256; the keystore password is kept in: " +
(DesktopKeyStore.passwordSourceDescription ?: "…"),
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(16.dp))
SectionTitle("Diagnostics")
AmberOutlinedButton(text = "View logs", onClick = { showLogsDialog = true })
@@ -0,0 +1,108 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.KeystorePassword
import com.greenart7c3.nostrsigner.desktop.core.PasswordStore
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
private class FakeStore(
var value: String? = null,
var available: Boolean = true,
override val description: String = "fake",
) : PasswordStore {
override fun load(): String? = if (available) value else null
override fun store(secret: String): Boolean {
if (!available) return false
value = secret
return true
}
override fun delete() {
value = null
}
}
class KeystorePasswordTest {
@Test
fun freshInstallPrefersOsStore() {
val os = FakeStore()
val file = FakeStore()
val resolved = KeystorePassword.resolve(os, file, keystoreExists = false) { true }
assertEquals(os, resolved.source)
assertEquals(resolved.password, os.value)
assertNull(file.value)
}
@Test
fun freshInstallFallsBackToFile() {
val os = FakeStore(available = false)
val file = FakeStore()
val resolved = KeystorePassword.resolve(os, file, keystoreExists = false) { true }
assertEquals(file, resolved.source)
assertEquals(resolved.password, file.value)
}
@Test
fun existingKeystoreUsesOsValueAndDropsFileCopy() {
val os = FakeStore(value = "os-secret")
val file = FakeStore(value = "os-secret")
val resolved = KeystorePassword.resolve(os, file, keystoreExists = true) { it == "os-secret" }
assertEquals(os, resolved.source)
assertEquals("os-secret", resolved.password)
assertNull(file.value)
}
@Test
fun legacyPasswordFileMigratesIntoOsStore() {
val os = FakeStore(value = null)
val file = FakeStore(value = "legacy-secret")
val resolved = KeystorePassword.resolve(os, file, keystoreExists = true) { it == "legacy-secret" }
assertEquals(os, resolved.source)
assertEquals("legacy-secret", resolved.password)
assertEquals("legacy-secret", os.value)
assertNull(file.value)
}
@Test
fun legacyPasswordFileStaysWhenOsStoreUnavailable() {
val os = FakeStore(available = false)
val file = FakeStore(value = "legacy-secret")
val resolved = KeystorePassword.resolve(os, file, keystoreExists = true) { it == "legacy-secret" }
assertEquals(file, resolved.source)
assertEquals("legacy-secret", file.value)
}
@Test
fun wrongOsValueFallsBackToWorkingFileValue() {
// e.g. a stale credential-store entry from a wiped install.
val os = FakeStore(value = "stale-secret")
val file = FakeStore(value = "real-secret")
val resolved = KeystorePassword.resolve(os, file, keystoreExists = true) { it == "real-secret" }
assertEquals("real-secret", resolved.password)
// The working password replaces the stale credential-store entry.
assertEquals("real-secret", os.value)
assertNull(file.value)
}
@Test
fun noWorkingPasswordThrows() {
val os = FakeStore(value = "wrong")
val file = FakeStore(value = null)
assertThrows(IllegalStateException::class.java) {
KeystorePassword.resolve(os, file, keystoreExists = true) { false }
}
}
@Test
fun generatedPasswordsAreUniqueAndLong() {
val a = KeystorePassword.generate()
val b = KeystorePassword.generate()
assertFalse(a == b)
assertTrue(a.length >= 40)
}
}
+2
View File
@@ -89,6 +89,8 @@ kmptor-runtime = { module = "io.matthewnelson.kmp-tor:runtime", version.ref = "k
kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" }
secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1Jni" }
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" }
[plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" }