Wire desktop persistence, account storage, and relay connectivity

AccountStore generates/imports the desktop account's key and persists it
encrypted via SecureCryptoHelper. SqliteBunkerPermissionStore/
SqliteBunkerHistoryLogger implement :shared's BunkerPermissionStore/
BunkerHistoryLogger against a local SQLite database (org.xerial:sqlite-jdbc,
schema created on first run under ~/.amber-bunker/).

BunkerRelayConnection wires Quartz's own NostrClient + BasicOkHttpWebSocket
(both already portable to the JVM target, confirmed via the quartz-jvm
Gradle variant) to subscribe for kind-24133 requests addressed to the
account pubkey, hand each one to BunkerSigningEngine, and publish the
signed response back to the same relay set.
This commit is contained in:
Claude
2026-07-01 18:00:27 +00:00
parent ee6bfa6d6d
commit 2ea7985799
6 changed files with 258 additions and 4 deletions
+2 -2
View File
@@ -20,9 +20,9 @@ dependencies {
implementation(libs.quartz.multiplatform)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.okhttp)
implementation(libs.core) // zxing core, for bunker connection QR codes
implementation(libs.java.keyring)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.xerial.sqlite.jdbc)
testImplementation(kotlin("test"))
}
@@ -0,0 +1,36 @@
package com.greenart7c3.nostrsigner.desktop.data
import com.greenart7c3.nostrsigner.shared.SecureCryptoHelper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Loads/persists the single desktop bunker account's private key, encrypted at rest via [SecureCryptoHelper]. */
object AccountStore {
private val keyFile get() = AppDataDir.file("account.key")
suspend fun hasAccount(): Boolean = withContext(Dispatchers.IO) { keyFile.exists() }
/** Loads the persisted account, or null if none has been set up yet. */
suspend fun load(): KeyPair? {
if (!hasAccount()) return null
val encrypted = withContext(Dispatchers.IO) { keyFile.readText() }
val privKeyHex = SecureCryptoHelper.decrypt(encrypted)
return KeyPair(privKey = privKeyHex.hexToByteArray())
}
/** Generates a brand-new key and persists it. */
suspend fun generate(): KeyPair = save(KeyPair())
/** Imports an existing hex or nsec-decoded private key and persists it. */
suspend fun import(privKeyHex: String): KeyPair = save(KeyPair(privKey = privKeyHex.hexToByteArray()))
private suspend fun save(keyPair: KeyPair): KeyPair {
val privKeyHex = requireNotNull(keyPair.privKey) { "Generated key pair is missing a private key" }.toHexKey()
val encrypted = SecureCryptoHelper.encrypt(privKeyHex)
withContext(Dispatchers.IO) { keyFile.writeText(encrypted) }
return keyPair
}
}
@@ -0,0 +1,63 @@
package com.greenart7c3.nostrsigner.desktop.data
import java.io.File
import java.sql.Connection
import java.sql.DriverManager
/** The on-disk home for all Amber Bunker desktop state: `~/.amber-bunker/`. */
object AppDataDir {
val directory: File by lazy {
File(System.getProperty("user.home"), ".amber-bunker").apply { mkdirs() }
}
fun file(name: String): File = File(directory, name)
}
/** Opens (and, on first run, creates the schema for) the desktop bunker's SQLite database. */
object BunkerDatabase {
private const val NO_KIND = -1
fun kindToColumn(kind: Int?): Int = kind ?: NO_KIND
fun columnToKind(value: Int): Int? = if (value == NO_KIND) null else value
fun open(): Connection {
val dbFile = AppDataDir.file("bunker.db")
val connection = DriverManager.getConnection("jdbc:sqlite:${dbFile.absolutePath}")
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS applications (
app_pub_key TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
connected_at INTEGER NOT NULL
)
""".trimIndent(),
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS permissions (
app_pub_key TEXT NOT NULL,
method TEXT NOT NULL,
kind INTEGER NOT NULL,
approved INTEGER NOT NULL,
PRIMARY KEY (app_pub_key, method, kind)
)
""".trimIndent(),
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_pub_key TEXT NOT NULL,
method TEXT NOT NULL,
kind INTEGER NOT NULL,
approved INTEGER NOT NULL,
time INTEGER NOT NULL
)
""".trimIndent(),
)
}
return connection
}
}
@@ -0,0 +1,90 @@
package com.greenart7c3.nostrsigner.desktop.data
import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry
import com.greenart7c3.nostrsigner.shared.BunkerHistoryLogger
import com.greenart7c3.nostrsigner.shared.BunkerMethod
import com.greenart7c3.nostrsigner.shared.BunkerPermissionStore
import java.sql.Connection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SqliteBunkerPermissionStore(private val connection: Connection) : BunkerPermissionStore {
override suspend fun isApproved(appPubKey: String, method: BunkerMethod, kind: Int?): Boolean? = withContext(Dispatchers.IO) {
connection.prepareStatement(
"SELECT approved FROM permissions WHERE app_pub_key = ? AND method = ? AND kind = ?",
).use { statement ->
statement.setString(1, appPubKey)
statement.setString(2, method.name)
statement.setInt(3, BunkerDatabase.kindToColumn(kind))
statement.executeQuery().use { rows ->
if (rows.next()) rows.getInt("approved") != 0 else null
}
}
}
override suspend fun remember(appPubKey: String, method: BunkerMethod, kind: Int?, approved: Boolean) {
withContext(Dispatchers.IO) {
connection.prepareStatement(
"""
INSERT INTO permissions (app_pub_key, method, kind, approved) VALUES (?, ?, ?, ?)
ON CONFLICT(app_pub_key, method, kind) DO UPDATE SET approved = excluded.approved
""".trimIndent(),
).use { statement ->
statement.setString(1, appPubKey)
statement.setString(2, method.name)
statement.setInt(3, BunkerDatabase.kindToColumn(kind))
statement.setInt(4, if (approved) 1 else 0)
statement.executeUpdate()
}
}
}
/** Revokes every stored rule for a connected app (used by the "connected apps" UI). */
suspend fun revokeAll(appPubKey: String) = withContext(Dispatchers.IO) {
connection.prepareStatement("DELETE FROM permissions WHERE app_pub_key = ?").use { statement ->
statement.setString(1, appPubKey)
statement.executeUpdate()
}
}
}
data class ConnectedApp(val pubKey: String, val name: String, val connectedAt: Long)
class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHistoryLogger {
override suspend fun log(entry: BunkerHistoryEntry) {
withContext(Dispatchers.IO) {
connection.prepareStatement(
"INSERT INTO history (app_pub_key, method, kind, approved, time) VALUES (?, ?, ?, ?, ?)",
).use { statement ->
statement.setString(1, entry.appPubKey)
statement.setString(2, entry.method.name)
statement.setInt(3, BunkerDatabase.kindToColumn(entry.kind))
statement.setInt(4, if (entry.approved) 1 else 0)
statement.setLong(5, entry.time)
statement.executeUpdate()
}
connection.prepareStatement(
"""
INSERT INTO applications (app_pub_key, connected_at) VALUES (?, ?)
ON CONFLICT(app_pub_key) DO UPDATE SET connected_at = excluded.connected_at
""".trimIndent(),
).use { statement ->
statement.setString(1, entry.appPubKey)
statement.setLong(2, entry.time)
statement.executeUpdate()
}
}
}
suspend fun connectedApps(): List<ConnectedApp> = withContext(Dispatchers.IO) {
connection.prepareStatement("SELECT app_pub_key, name, connected_at FROM applications ORDER BY connected_at DESC").use { statement ->
statement.executeQuery().use { rows ->
buildList {
while (rows.next()) {
add(ConnectedApp(rows.getString("app_pub_key"), rows.getString("name"), rows.getLong("connected_at")))
}
}
}
}
}
}
@@ -0,0 +1,65 @@
package com.greenart7c3.nostrsigner.desktop.relay
import com.greenart7c3.nostrsigner.shared.BunkerSigningEngine
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
/** Default relay set a freshly set-up bunker listens on; matches typical bunker:// connection strings. */
val DEFAULT_BUNKER_RELAYS: List<String> = listOf(
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
)
/** Owns the relay connection for the desktop bunker: subscribes for kind-24133 requests, hands them to [engine], publishes replies. */
class BunkerRelayConnection(
private val accountPubKey: String,
private val engine: BunkerSigningEngine,
private val scope: CoroutineScope,
relayUrls: List<String> = DEFAULT_BUNKER_RELAYS,
) : RelayConnectionListener {
val relays: Set<NormalizedRelayUrl> = relayUrls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
private val httpClient = OkHttpClient.Builder().build()
private val client = NostrClient(BasicOkHttpWebSocket.Builder { httpClient }, scope)
private val subId = UUID.randomUUID().toString()
fun start() {
client.addConnectionListener(this)
client.subscribe(
subId,
relays.associateWith {
listOf(Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(accountPubKey))))
},
)
client.connect()
}
fun stop() {
client.unsubscribe(subId)
client.disconnect()
client.removeConnectionListener(this)
}
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
if (msg is EventMessage && msg.subId == subId) {
scope.launch {
val response = engine.handleIncomingEvent(msg.event.pubKey, msg.event.content) ?: return@launch
client.publishAndConfirm(response, relays, timeoutInSeconds = 5)
}
}
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ kmpTor = "2.6.0"
kmpTorResource = "409.5.0"
secp256k1Jni = "0.23.0"
composeMultiplatform = "1.11.1"
androidxSqliteBundled = "2.6.2"
javaKeyring = "1.0.4"
xerialSqliteJdbc = "3.53.2.0"
[libraries]
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
@@ -88,8 +88,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
kmptor-runtime = { module = "io.matthewnelson.kmp-tor:runtime", version.ref = "kmpTor" }
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" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidxSqliteBundled" }
java-keyring = { module = "com.github.javakeyring:java-keyring", version.ref = "javaKeyring" }
xerial-sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "xerialSqliteJdbc" }
[plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" }