Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: New Crowdin translations by GitHub Action fix: resolve conflicts with upstream PR #1789 merge fix(desktop): remove noisy debug logs and silence SLF4J warnings feat(desktop): add bunker heartbeat indicator to sidebar navigation fix(desktop): verify user pubkey via get_public_key after nostrconnect login fix(desktop): address code review findings for NIP-46 bunker login feat(desktop): add nostrconnect:// login flow for QR-based signer pairing revert: remove QR code scanning from login feat(desktop): webcam QR scanning via ffmpeg for bunker login feat(desktop): add webcam QR code scanning for bunker login feat(desktop): add QR code scanning for bunker login fix(desktop): clear stored credentials on logout feat(desktop): update login hints to mention bunker:// option fix(desktop): simplify settings logout to just a button feat(desktop): add logout option to menu bar and settings test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors feat(desktop): add NIP-46 remote signer (bunker://) login
This commit is contained in:
+11
-1
@@ -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
|
||||
|
||||
+3
-2
@@ -41,8 +41,9 @@ class RemoteSignerManager(
|
||||
) {
|
||||
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
|
||||
|
||||
fun newResponse(responseEvent: NostrConnectEvent) {
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
|
||||
suspend fun newResponse(responseEvent: NostrConnectEvent) {
|
||||
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
|
||||
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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.quartz.nip46RemoteSigner
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for NostrConnectEvent pure logic (canDecrypt, talkingWith, verifiedRecipientPubKey).
|
||||
*
|
||||
* Crypto-dependent tests (create + decrypt roundtrip) live in androidDeviceTest/Nip46Test.kt
|
||||
* because NIP-44 encryption requires lazysodium which is only available on Android/device tests.
|
||||
*/
|
||||
class NostrConnectEventTest {
|
||||
private val senderKey = NostrSignerInternal(KeyPair())
|
||||
private val recipientKey = NostrSignerInternal(KeyPair())
|
||||
private val thirdPartyKey = NostrSignerInternal(KeyPair())
|
||||
|
||||
/** Construct a NostrConnectEvent with known pubKey and p-tag, no real crypto needed */
|
||||
private fun buildEvent(
|
||||
authorPubKey: String,
|
||||
recipientPubKey: String,
|
||||
) = NostrConnectEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = authorPubKey,
|
||||
createdAt = 1L,
|
||||
tags = arrayOf(arrayOf("p", recipientPubKey)),
|
||||
content = "encrypted-placeholder",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
|
||||
// --- canDecrypt ---
|
||||
|
||||
@Test
|
||||
fun canDecryptAsSender() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertTrue(event.canDecrypt(senderKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canDecryptAsRecipient() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertTrue(event.canDecrypt(recipientKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canDecryptUnauthorizedReturnsFalse() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertFalse(event.canDecrypt(thirdPartyKey))
|
||||
}
|
||||
|
||||
// --- talkingWith ---
|
||||
|
||||
@Test
|
||||
fun talkingWithAsSenderReturnsRecipient() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(recipientKey.pubKey, event.talkingWith(senderKey.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun talkingWithAsRecipientReturnsSender() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(senderKey.pubKey, event.talkingWith(recipientKey.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun talkingWithUnknownReturnsSender() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
// When oneSideHex doesn't match pubKey, returns pubKey (sender)
|
||||
assertEquals(senderKey.pubKey, event.talkingWith(thirdPartyKey.pubKey))
|
||||
}
|
||||
|
||||
// --- verifiedRecipientPubKey ---
|
||||
|
||||
@Test
|
||||
fun verifiedRecipientPubKeyWithValidHex() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(recipientKey.pubKey, event.verifiedRecipientPubKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifiedRecipientPubKeyWithInvalidHex() {
|
||||
val event =
|
||||
NostrConnectEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = senderKey.pubKey,
|
||||
createdAt = 1L,
|
||||
tags = arrayOf(arrayOf("p", "not-hex!")),
|
||||
content = "encrypted",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
assertNull(event.verifiedRecipientPubKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifiedRecipientPubKeyWithNoPTag() {
|
||||
val event =
|
||||
NostrConnectEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = senderKey.pubKey,
|
||||
createdAt = 1L,
|
||||
tags = emptyArray(),
|
||||
content = "encrypted",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
assertNull(event.verifiedRecipientPubKey())
|
||||
}
|
||||
|
||||
// --- Kind ---
|
||||
|
||||
@Test
|
||||
fun kindIs24133() {
|
||||
assertEquals(24133, NostrConnectEvent.KIND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventHasCorrectKind() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(24133, event.kind)
|
||||
}
|
||||
|
||||
// --- isContentEncoded ---
|
||||
|
||||
@Test
|
||||
fun isContentEncodedReturnsTrue() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertTrue(event.isContentEncoded())
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConvertExceptionsTest {
|
||||
private val remote =
|
||||
NostrSignerRemote.fromBunkerUri(
|
||||
"bunker://${"a".repeat(64)}?relay=wss://r.com",
|
||||
NostrSignerInternal(KeyPair()),
|
||||
EmptyNostrClient,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun successfulReturnsBug() {
|
||||
val result = SignerResult.RequestAddressed.Successful(PingResult("pong"))
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<IllegalStateException>(ex)
|
||||
assertTrue(ex.message!!.contains("bug"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedReturnsManuallyUnauthorized() {
|
||||
val result = SignerResult.RequestAddressed.Rejected<PingResult>()
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<SignerExceptions.ManuallyUnauthorizedException>(ex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timedOutReturnsTimedOutException() {
|
||||
val result = SignerResult.RequestAddressed.TimedOut<PingResult>()
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<SignerExceptions.TimedOutException>(ex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun couldNotPerformReturnsCouldNotPerformException() {
|
||||
val result = SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PingResult>("custom msg")
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<SignerExceptions.CouldNotPerformException>(ex)
|
||||
assertTrue(ex.message!!.contains("custom msg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun couldNotParseReturnsIllegalState() {
|
||||
val result = SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<PingResult>("{bad}")
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<IllegalStateException>(ex)
|
||||
assertTrue(ex.message!!.contains("{bad}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun couldNotVerifyReturnsIllegalState() {
|
||||
val event =
|
||||
Event(
|
||||
id = "a".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = 1L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
val result = SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<PingResult>(event)
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<IllegalStateException>(ex)
|
||||
assertTrue(ex.message!!.contains("verify"))
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class FromBunkerUriTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val client = EmptyNostrClient
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun validSingleRelay() {
|
||||
val uri = "bunker://$validHex?relay=wss://relay.example.com"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals(1, remote.relays.size)
|
||||
assertEquals(validHex, remote.remotePubkey)
|
||||
assertNull(remote.secret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validMultipleRelays() {
|
||||
val uri = "bunker://$validHex?relay=wss://a.com&relay=wss://b.com"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals(2, remote.relays.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validWithSecret() {
|
||||
val uri = "bunker://$validHex?relay=wss://r.com&secret=abc123"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals("abc123", remote.secret)
|
||||
assertEquals(1, remote.relays.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingSchemeThrows() {
|
||||
assertFailsWith<Exception> {
|
||||
NostrSignerRemote.fromBunkerUri("npub1abc", signer, client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidHexPubkeyThrows() {
|
||||
assertFailsWith<Exception> {
|
||||
NostrSignerRemote.fromBunkerUri("bunker://notHex?relay=wss://r.com", signer, client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingQueryParamsThrows() {
|
||||
assertFailsWith<Exception> {
|
||||
NostrSignerRemote.fromBunkerUri("bunker://$validHex", signer, client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsMalformedParams() {
|
||||
val uri = "bunker://$validHex?relay=wss://r.com&badparam"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals(1, remote.relays.size)
|
||||
assertNull(remote.secret)
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* 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.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* INostrClient that records openReqSubscription calls for verification.
|
||||
* Used instead of mockk since commonTest doesn't have mockk.
|
||||
*/
|
||||
private class TrackingNostrClient : INostrClient {
|
||||
data class SubscriptionRecord(
|
||||
val subId: String,
|
||||
val filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
)
|
||||
|
||||
val subscriptions = mutableListOf<SubscriptionRecord>()
|
||||
val sentEvents = mutableListOf<Pair<Event, Set<NormalizedRelayUrl>>>()
|
||||
|
||||
override fun connectedRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
|
||||
|
||||
override fun availableRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
|
||||
|
||||
override fun connect() {}
|
||||
|
||||
override fun disconnect() {}
|
||||
|
||||
override fun reconnect(
|
||||
onlyIfChanged: Boolean,
|
||||
ignoreRetryDelays: Boolean,
|
||||
) {}
|
||||
|
||||
override fun isActive() = false
|
||||
|
||||
override fun renewFilters(relay: IRelayClient) {}
|
||||
|
||||
override fun openReqSubscription(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener?,
|
||||
) {
|
||||
subscriptions.add(SubscriptionRecord(subId, filters))
|
||||
}
|
||||
|
||||
override fun queryCount(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {}
|
||||
|
||||
override fun close(subId: String) {}
|
||||
|
||||
override fun send(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
sentEvents.add(event to relayList)
|
||||
}
|
||||
|
||||
override fun subscribe(listener: IRelayClientListener) {}
|
||||
|
||||
override fun unsubscribe(listener: IRelayClientListener) {}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests verifying NIP-46 relay isolation at the NostrSignerRemote level.
|
||||
* Ensures subscription filters only target bunker relays and contain
|
||||
* the correct kind + p-tag.
|
||||
*/
|
||||
class NostrSignerRemoteIsolationTest {
|
||||
private val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
|
||||
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun subscriptionFilterTargetsOnlyBunkerRelays() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Construction triggers client.req() which calls openReqSubscription
|
||||
NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
// Verify subscription was opened
|
||||
assertTrue(
|
||||
trackingClient.subscriptions.isNotEmpty(),
|
||||
"No subscription opened on construction",
|
||||
)
|
||||
|
||||
// Verify ALL filters only target the bunker relay
|
||||
trackingClient.subscriptions.forEach { record ->
|
||||
assertTrue(
|
||||
record.filters.keys.all { it == bunkerRelay },
|
||||
"Filter contains non-bunker relay: ${record.filters.keys}",
|
||||
)
|
||||
assertTrue(
|
||||
generalRelay !in record.filters.keys,
|
||||
"General relay leaked into NIP-46 subscription",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionFilterContainsCorrectKindAndPTag() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
|
||||
NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
val allFilters =
|
||||
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
|
||||
assertTrue(allFilters.isNotEmpty(), "No filters captured")
|
||||
|
||||
allFilters.forEach { filter ->
|
||||
// Must filter for NIP-46 event kind (24133)
|
||||
assertTrue(
|
||||
filter.kinds?.contains(NostrConnectEvent.KIND) == true,
|
||||
"Filter missing kind ${NostrConnectEvent.KIND}, got: ${filter.kinds}",
|
||||
)
|
||||
|
||||
// Must filter for our ephemeral pubkey in p-tag
|
||||
val pTags = filter.tags?.get("p")
|
||||
assertNotNull(pTags, "Filter missing p-tag")
|
||||
assertTrue(
|
||||
pTags.contains(ephemeralSigner.pubKey),
|
||||
"Filter p-tag doesn't contain ephemeral pubkey",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleRelaysAllIncludedInFilter() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val relay1 = NormalizedRelayUrl("wss://relay1.nsec.app/")
|
||||
val relay2 = NormalizedRelayUrl("wss://relay2.nsec.app/")
|
||||
|
||||
NostrSignerRemote(
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(relay1, relay2),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
assertTrue(trackingClient.subscriptions.isNotEmpty())
|
||||
val allRelayKeys =
|
||||
trackingClient.subscriptions.flatMap { it.filters.keys }.toSet()
|
||||
|
||||
assertTrue(relay1 in allRelayKeys, "Missing relay1 in subscription")
|
||||
assertTrue(relay2 in allRelayKeys, "Missing relay2 in subscription")
|
||||
// General relay should NOT be present
|
||||
assertTrue(
|
||||
generalRelay !in allRelayKeys,
|
||||
"General relay leaked into multi-relay subscription",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify subscription filter does NOT use a `since` timestamp,
|
||||
* matching upstream behavior (PR #1789 removed it).
|
||||
*/
|
||||
@Test
|
||||
fun subscriptionFilterHasNoSinceTimestamp() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
|
||||
NostrSignerRemote(
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
val allFilters =
|
||||
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
|
||||
assertTrue(allFilters.isNotEmpty())
|
||||
|
||||
allFilters.forEach { filter ->
|
||||
assertTrue(
|
||||
filter.since == null,
|
||||
"Filter should not have a 'since' timestamp",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromBunkerUriPreservesRelayIsolation() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||
|
||||
val remote =
|
||||
NostrSignerRemote.fromBunkerUri(
|
||||
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||
ephemeralSigner,
|
||||
trackingClient,
|
||||
)
|
||||
|
||||
// Verify relay set matches URI
|
||||
assertEquals(1, remote.relays.size)
|
||||
val parsedRelay = remote.relays.first()
|
||||
assertTrue(
|
||||
parsedRelay.url.contains("relay.nsec.app"),
|
||||
"Parsed relay doesn't match URI: ${parsedRelay.url}",
|
||||
)
|
||||
|
||||
// Verify subscription was opened targeting ONLY the bunker relay
|
||||
assertTrue(trackingClient.subscriptions.isNotEmpty())
|
||||
trackingClient.subscriptions.forEach { record ->
|
||||
assertTrue(
|
||||
record.filters.keys.all { it in remote.relays },
|
||||
"fromBunkerUri subscription targets wrong relays: ${record.filters.keys}",
|
||||
)
|
||||
// General relay must NOT appear
|
||||
assertTrue(
|
||||
generalRelay !in record.filters.keys,
|
||||
"General relay leaked into fromBunkerUri subscription",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user