mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
+90
@@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user