diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 792e02152..be0915bc6 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -49,6 +49,9 @@ dependencies { // Collections implementation(libs.kotlinx.collections.immutable) + // QR code generation (ZXing core) + implementation(libs.zxing) + // Testing testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 61e60b098..bd0ca588a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -23,10 +23,15 @@ package com.vitorpamplona.amethyst.desktop.account import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -34,8 +39,13 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -44,7 +54,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import java.io.File +import java.security.SecureRandom sealed class SignerType { data object Internal : SignerType() @@ -93,6 +105,8 @@ class AccountManager internal constructor( internal const val HEARTBEAT_INTERVAL_MS = 60_000L internal const val MAX_CONSECUTIVE_FAILURES = 3 internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" + internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L + internal val NIP46_RELAYS = listOf("wss://relay.nsec.app") } private val amethystDir: File by lazy { @@ -226,6 +240,148 @@ class AccountManager internal constructor( Result.failure(Exception("Connection failed: ${e.message}")) } + // --- Nostrconnect login --- + + suspend fun loginWithNostrConnect( + client: INostrClient, + onUriGenerated: (String) -> Unit, + ): Result = + try { + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val secret = generateNostrConnectSecret() + val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() + + val relays = NIP46_RELAYS + val relayParams = relays.joinToString("&") { "relay=$it" } + val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop" + onUriGenerated(uri) + + val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() + val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, client) + + sendAckResponse(ephemeralSigner, connectData, normalizedRelays, client) + + val remoteSigner = + NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = connectData.signerPubkey, + relays = normalizedRelays, + client = client, + ) + remoteSigner.openSubscription() + + val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams" + + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = connectData.userPubkey, + npub = connectData.userPubkey.hexToByteArray().toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(syntheticBunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + + saveBunkerAccount( + bunkerUri = syntheticBunkerUri, + ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), + npub = state.npub, + ) + + Result.success(state) + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code.")) + } catch (e: Exception) { + Result.failure(Exception("Connection failed: ${e.message}")) + } + + private suspend fun waitForConnectRequest( + ephemeralSigner: NostrSignerInternal, + ephemeralPubKey: HexKey, + relays: Set, + expectedSecret: String, + client: INostrClient, + ): ConnectRequestData { + val deferred = CompletableDeferred() + + val subscription = + client.req( + relays = relays.toList(), + filter = + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(ephemeralPubKey)), + ), + ) { event -> + if (event is NostrConnectEvent && !deferred.isCompleted) { + deferred.complete(event) + } + } + + try { + val event = + withTimeout(NOSTRCONNECT_TIMEOUT_MS) { + deferred.await() + } + + val otherPubkey = event.talkingWith(ephemeralSigner.pubKey) + val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey) + val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) + + if (message.method != BunkerRequestConnect.METHOD_NAME) { + throw Exception("Expected 'connect' method, got '${message.method}'") + } + + val userPubkey = + message.params.getOrNull(0) + ?: throw Exception("Missing user pubkey in connect request") + val receivedSecret = message.params.getOrNull(1) + + if (receivedSecret != expectedSecret) { + throw Exception("Secret mismatch in connect request") + } + + return ConnectRequestData( + requestId = message.id, + signerPubkey = event.pubKey, + userPubkey = userPubkey, + ) + } finally { + subscription.close() + } + } + + private suspend fun sendAckResponse( + ephemeralSigner: NostrSignerInternal, + connectData: ConnectRequestData, + relays: Set, + client: INostrClient, + ) { + val ackResponse = BunkerResponseAck(id = connectData.requestId) + val ackEvent = + NostrConnectEvent.create( + message = ackResponse, + remoteKey = connectData.signerPubkey, + signer = ephemeralSigner, + ) + client.send(ackEvent, relays) + } + + private fun generateNostrConnectSecret(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private data class ConnectRequestData( + val requestId: String, + val signerPubkey: HexKey, + val userPubkey: HexKey, + ) + private suspend fun saveBunkerAccount( bunkerUri: String, ephemeralPrivKeyHex: String, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 1bb7cc64d..880214190 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -106,6 +106,16 @@ fun LoginScreen( } else { null }, + onLoginNostrConnect = + if (relayClient != null) { + { onUriGenerated -> + accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map { + onLoginSuccess() + } + } + } else { + null + }, ) val account = generatedAccount diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 39d56a42c..a90fe81f9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -30,21 +30,30 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res @@ -83,16 +92,14 @@ fun LoginCard( onLogin: (String) -> Result, onGenerateNew: () -> Unit, onLoginBunker: (suspend (String) -> Result)? = null, + onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result)? = null, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, title: String = stringResource(Res.string.login_card_title), subtitle: String = stringResource(Res.string.login_card_subtitle), ) { - var keyInput by remember { mutableStateOf("") } - var errorMessage by remember { mutableStateOf(null) } - var isConnecting by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true) + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("Paste Key", "Connect") Card( modifier = modifier.width(cardWidth), @@ -113,97 +120,254 @@ fun LoginCard( Spacer(Modifier.height(16.dp)) - KeyInputField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null + if (onLoginNostrConnect != null) { + @Suppress("DEPRECATION") + PrimaryTabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)), + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + } + + when (selectedTab) { + 0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle) + 1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect) + } + } + } +} + +@Composable +private fun PasteKeyContent( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + onLoginBunker: (suspend (String) -> Result)?, + subtitle: String, +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true) + + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + if (isBunker) { + Text( + "This URI connects to your remote signer. Treat it like a password.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + if (isConnecting) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Connecting to remote signer...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (isBunker && onLoginBunker != null) { + val validationError = validateBunkerUri(keyInput) + if (validationError != null) { + errorMessage = validationError + return@Button + } + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = onLoginBunker(keyInput.trim()) + withContext(Dispatchers.Main) { + result.fold( + onSuccess = { isConnecting = false }, + onFailure = { + errorMessage = it.message + isConnecting = false + }, + ) + } + } + } else { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + } }, - errorMessage = errorMessage, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button)) + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.login_generate_button)) + } + } + } +} + +@Composable +private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result) { + var nostrConnectUri by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + @Suppress("DEPRECATION") + val clipboardManager = LocalClipboardManager.current + + if (errorMessage != null) { + Text( + errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = { + errorMessage = null + nostrConnectUri = null + }) { + Text("Try Again") + } + } else if (!isConnecting) { + Text( + "Show a QR code for your signer app (e.g. Amber) to scan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = + onLoginNostrConnect { uri -> + nostrConnectUri = uri + } + withContext(Dispatchers.Main) { + result.onFailure { + errorMessage = it.message + isConnecting = false + nostrConnectUri = null + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Start Connection") + } + } else { + val uri = nostrConnectUri + if (uri != null) { + QrCodeCanvas( + data = uri, + size = 200.dp, + ) + + Spacer(Modifier.height(12.dp)) + + Text( + uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) - if (isBunker) { - Text( - "This URI connects to your remote signer. Treat it like a password.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + OutlinedButton( + onClick = { clipboardManager.setText(AnnotatedString(uri)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Copy URI") + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, ) - } else { + Spacer(Modifier.width(8.dp)) Text( - subtitle, + "Waiting for signer to connect...", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - Spacer(Modifier.height(16.dp)) - - if (isConnecting) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(12.dp)) - Text( - "Connecting to remote signer...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - if (isBunker && onLoginBunker != null) { - val validationError = validateBunkerUri(keyInput) - if (validationError != null) { - errorMessage = validationError - return@Button - } - isConnecting = true - errorMessage = null - scope.launch(Dispatchers.IO) { - val result = onLoginBunker(keyInput.trim()) - withContext(Dispatchers.Main) { - result.fold( - onSuccess = { isConnecting = false }, - onFailure = { - errorMessage = it.message - isConnecting = false - }, - ) - } - } - } else { - onLogin(keyInput).fold( - onSuccess = { /* handled by caller */ }, - onFailure = { errorMessage = it.message }, - ) - } - }, - modifier = Modifier.weight(1f), - enabled = keyInput.isNotBlank(), - ) { - Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button)) - } - - OutlinedButton( - onClick = onGenerateNew, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.login_generate_button)) - } - } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Generating connection...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt new file mode 100644 index 000000000..68320b18b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.auth + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter + +@Composable +fun QrCodeCanvas( + data: String, + modifier: Modifier = Modifier, + size: Dp = 200.dp, + foreground: Color = MaterialTheme.colorScheme.onSurface, + background: Color = Color.White, +) { + val matrix = + remember(data) { + QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + 0, + 0, + mapOf(EncodeHintType.MARGIN to 1), + ) + } + + Canvas(modifier = modifier.size(size)) { + val cellWidth = this.size.width / matrix.width + val cellHeight = this.size.height / matrix.height + + drawRect(background, Offset.Zero, this.size) + + for (x in 0 until matrix.width) { + for (y in 0 until matrix.height) { + if (matrix.get(x, y)) { + drawRect( + color = foreground, + topLeft = Offset(x * cellWidth, y * cellHeight), + size = Size(cellWidth, cellHeight), + ) + } + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index fb2e344a8..da0c3b63e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -42,6 +42,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch class NostrSignerRemote( val signer: NostrSignerInternal, @@ -51,6 +56,8 @@ class NostrSignerRemote( val permissions: String? = null, val secret: String? = null, ) : NostrSigner(signer.pubKey) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val manager = RemoteSignerManager( signer = signer, @@ -69,7 +76,13 @@ class NostrSignerRemote( ), ) { event -> if (event is NostrConnectEvent) { - manager.newResponse(event) + scope.launch { + try { + manager.newResponse(event) + } catch (_: Exception) { + // Ignore events we can't decrypt or parse + } + } } } @@ -79,6 +92,7 @@ class NostrSignerRemote( fun closeSubscription() { subscription.close() + scope.cancel() } override fun isWriteable(): Boolean = true diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt index e8a4c6ad1..adae21dfa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -41,8 +41,9 @@ class RemoteSignerManager( ) { private val awaitingRequests = LargeCache>() - fun newResponse(responseEvent: NostrConnectEvent) { - val bunkerResponse = OptimizedJsonMapper.fromJsonTo(responseEvent.content) + suspend fun newResponse(responseEvent: NostrConnectEvent) { + val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) + val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) }