fix: resolve conflicts with upstream PR #1789 merge

- Add missing commons domain files (BunkerLoginUseCase, NostrConnectLoginUseCase)
- Restore CoroutineScope in NostrSignerRemote for suspend decrypt call
- Update isolation test: upstream removed `since` filter (PR #1789)
- Add new INostrClient interface methods to TrackingNostrClient mock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-11 10:35:56 +02:00
parent 36efbd1544
commit edf764709d
5 changed files with 320 additions and 21 deletions
@@ -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,
)
@@ -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)
}
}
@@ -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<NormalizedRelayUrl>,
val secret: String,
)
data class ConnectRequestData(
val requestId: String?,
val signerPubkey: HexKey,
val userPubkey: HexKey,
)
fun generateUri(
ephemeralKeyPair: KeyPair,
relays: List<String>,
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<NormalizedRelayUrl>,
expectedSecret: String,
client: INostrClient,
): ConnectRequestData {
val deferred = CompletableDeferred<NostrConnectEvent>()
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<BunkerMessage>(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<NormalizedRelayUrl>,
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) }
}
}
@@ -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,9 +76,11 @@ class NostrSignerRemote(
),
) { event ->
if (event is NostrConnectEvent) {
scope.launch {
manager.newResponse(event)
}
}
}
fun openSubscription() {
subscription.updateFilter()
@@ -79,6 +88,7 @@ class NostrSignerRemote(
fun closeSubscription() {
subscription.close()
scope.cancel()
}
override fun isWriteable(): Boolean = true
@@ -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<NormalizedRelayUrl, List<Filter>>? = null
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = 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",
)
}
}