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