diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt new file mode 100644 index 000000000..42589c369 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt @@ -0,0 +1,29 @@ +/* + * 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.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote + +data class BunkerLoginResult( + val signer: NostrSignerRemote, + val pubKeyHex: HexKey, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt new file mode 100644 index 000000000..4962f5e3e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt @@ -0,0 +1,51 @@ +/* + * 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.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout + +object BunkerLoginUseCase { + const val RELAY_CONNECT_TIMEOUT_MS = 15_000L + + suspend fun execute( + bunkerUri: String, + ephemeralSigner: NostrSignerInternal, + client: INostrClient, + ): BunkerLoginResult { + val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) + remoteSigner.openSubscription() + + // Wait for websocket to be ready before sending connect request + withTimeout(RELAY_CONNECT_TIMEOUT_MS) { + client.connectedRelaysFlow().first { connected -> + remoteSigner.relays.any { it in connected } + } + } + + remoteSigner.connect() + val pubkey = remoteSigner.getPublicKey() + return BunkerLoginResult(remoteSigner, pubkey) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt new file mode 100644 index 000000000..674a38219 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt @@ -0,0 +1,218 @@ +/* + * 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.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +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.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.SecureRandom +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout + +object NostrConnectLoginUseCase { + const val NOSTRCONNECT_TIMEOUT_MS = 120_000L + + data class NostrConnectUri( + val uri: String, + val ephemeralKeyPair: KeyPair, + val ephemeralSigner: NostrSignerInternal, + val relays: Set, + val secret: String, + ) + + data class ConnectRequestData( + val requestId: String?, + val signerPubkey: HexKey, + val userPubkey: HexKey, + ) + + fun generateUri( + ephemeralKeyPair: KeyPair, + relays: List, + appName: String, + ): NostrConnectUri { + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() + val secret = generateSecret() + + val relayParams = relays.joinToString("&") { "relay=$it" } + val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=$appName" + val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() + + return NostrConnectUri( + uri = uri, + ephemeralKeyPair = ephemeralKeyPair, + ephemeralSigner = ephemeralSigner, + relays = normalizedRelays, + secret = secret, + ) + } + + suspend fun awaitAndLogin( + uriData: NostrConnectUri, + client: INostrClient, + ): BunkerLoginResult { + val connectData = + waitForConnectRequest( + ephemeralSigner = uriData.ephemeralSigner, + ephemeralPubKey = uriData.ephemeralKeyPair.pubKey.toHexKey(), + relays = uriData.relays, + expectedSecret = uriData.secret, + client = client, + ) + + if (connectData.requestId != null) { + sendAckResponse(uriData.ephemeralSigner, connectData, uriData.relays, client) + } + + val remoteSigner = + NostrSignerRemote( + signer = uriData.ephemeralSigner, + remotePubkey = connectData.signerPubkey, + relays = uriData.relays, + client = client, + ) + remoteSigner.openSubscription() + + val verifiedPubkey = remoteSigner.getPublicKey() + + return BunkerLoginResult(remoteSigner, verifiedPubkey) + } + + 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)), + since = TimeUtils.now() - 60, + ), + ) { event -> + if (event is NostrConnectEvent && !deferred.isCompleted) { + deferred.complete(event) + } + } + + try { + val event = + withTimeout(NOSTRCONNECT_TIMEOUT_MS) { + deferred.await() + } + + val signerPubkey = event.talkingWith(ephemeralSigner.pubKey) + val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey) + val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) + + return when (message) { + is BunkerRequest -> { + 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") + } + + ConnectRequestData( + requestId = message.id, + signerPubkey = signerPubkey, + userPubkey = userPubkey, + ) + } + + is BunkerResponse -> { + if (message.error != null) { + throw Exception("Signer rejected connection: ${message.error}") + } + + val userPubkey = + message.result + ?.takeIf { it.length == 64 && Hex.isHex(it) } + ?: signerPubkey + + ConnectRequestData( + requestId = null, + signerPubkey = signerPubkey, + userPubkey = userPubkey, + ) + } + + else -> { + throw Exception("Unexpected NIP-46 message format") + } + } + } 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 generateSecret(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } +} 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..515043d44 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,9 @@ class NostrSignerRemote( ), ) { event -> if (event is NostrConnectEvent) { - manager.newResponse(event) + scope.launch { + manager.newResponse(event) + } } } @@ -79,6 +88,7 @@ class NostrSignerRemote( fun closeSubscription() { subscription.close() + scope.cancel() } override fun isWriteable(): Boolean = true diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index 3bad658c7..95432f61f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertEquals @@ -96,6 +95,12 @@ private class TrackingNostrClient : INostrClient { override fun getReqFiltersOrNull(subId: String): Map>? = null override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() } /** @@ -201,15 +206,12 @@ class NostrSignerRemoteIsolationTest { } /** - * TDD RED TEST — will fail until Step 8 adds `since` filter. - * - * Reproduces Bug 3 mitigation: stale NIP-46 responses from previous - * sessions should be filtered out via `since` timestamp. + * Verify subscription filter does NOT use a `since` timestamp, + * matching upstream behavior (PR #1789 removed it). */ @Test - fun subscriptionFilterHasSinceTimestamp() { + fun subscriptionFilterHasNoSinceTimestamp() { val trackingClient = TrackingNostrClient() - val beforeTime = TimeUtils.now() NostrSignerRemote( signer = NostrSignerInternal(KeyPair()), @@ -223,20 +225,9 @@ class NostrSignerRemoteIsolationTest { assertTrue(allFilters.isNotEmpty()) allFilters.forEach { filter -> - val since = - assertNotNull( - filter.since, - "Filter missing 'since' timestamp — stale responses won't be filtered", - ) - // since should be roughly now - 60s (with tolerance) - val expectedMin = beforeTime - 120 // extra tolerance for test execution time assertTrue( - since >= expectedMin, - "since too old: $since (expected >= $expectedMin)", - ) - assertTrue( - since <= beforeTime, - "since in the future: $since", + filter.since == null, + "Filter should not have a 'since' timestamp", ) } }