diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index a6d6c045..1febf242 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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")) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt new file mode 100644 index 00000000..9c05c2aa --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/AccountStore.kt @@ -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 + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt new file mode 100644 index 00000000..f25a88cf --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt @@ -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 + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt new file mode 100644 index 00000000..8717c34a --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt @@ -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 = 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"))) + } + } + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/relay/BunkerRelayConnection.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/relay/BunkerRelayConnection.kt new file mode 100644 index 00000000..38f14398 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/relay/BunkerRelayConnection.kt @@ -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 = 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 = DEFAULT_BUNKER_RELAYS, +) : RelayConnectionListener { + val relays: Set = 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) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 103b6c21..fe584471 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }