diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt new file mode 100644 index 0000000000..004fdcb4ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt @@ -0,0 +1,193 @@ +/* + * 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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Hex + +/** + * KMP-safe parsing and building of the two NIP-46 pairing URIs: + * + * - `bunker://?relay=…&secret=…` — the **signer** + * advertises how to reach it (Amethyst mints this when it acts as a bunker). + * - `nostrconnect://?relay=…&secret=…&perms=…&name=…&url=…&image=…` + * — the **client** offers to connect and the signer answers with a connect + * ack that echoes the secret (Amethyst parses this when a user pastes an + * offer to pair a new app). + * + * Values are percent-encoded per the spec/nak convention (`relay=wss%3A%2F%2F…`); + * this object encodes/decodes without any JVM-only `java.net.URLEncoder`, so it + * lives in `commonMain` and is shared by the CLI, desktop and Android. + */ +object NostrConnectURI { + const val BUNKER_SCHEME = "bunker://" + const val NOSTRCONNECT_SCHEME = "nostrconnect://" + + /** A parsed `bunker://` advertisement. */ + data class Bunker( + val remoteSignerPubKey: HexKey, + val relays: Set, + val secret: String?, + ) + + /** A parsed `nostrconnect://` client offer. */ + data class NostrConnect( + val clientPubKey: HexKey, + val relays: Set, + val secret: String, + val perms: String? = null, + val name: String? = null, + val url: String? = null, + val image: String? = null, + ) + + /** Parse a `bunker://?relay=…&secret=…` URI, or `null` if malformed. */ + fun parseBunker(uri: String): Bunker? { + if (!uri.startsWith(BUNKER_SCHEME)) return null + val (pubkey, params) = splitAuthority(uri.removePrefix(BUNKER_SCHEME)) ?: return null + if (!isValidPubKey(pubkey)) return null + val relays = mutableSetOf() + var secret: String? = null + forEachParam(params) { key, value -> + when (key) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + } + } + return Bunker(pubkey, relays, secret) + } + + /** Build a `bunker://?relay=…&secret=…` advertisement URI. */ + fun buildBunker( + remoteSignerPubKey: HexKey, + relays: Collection, + secret: String?, + ): String = + buildString { + append(BUNKER_SCHEME).append(remoteSignerPubKey) + append('?').append(relays.joinToString("&") { "relay=${encode(it.url)}" }) + if (secret != null) append("&secret=").append(encode(secret)) + } + + /** Parse a `nostrconnect://?relay=…&secret=…&…` offer, or `null` if malformed. */ + fun parseNostrConnect(uri: String): NostrConnect? { + if (!uri.startsWith(NOSTRCONNECT_SCHEME)) return null + val (pubkey, params) = splitAuthority(uri.removePrefix(NOSTRCONNECT_SCHEME)) ?: return null + if (!isValidPubKey(pubkey)) return null + val relays = mutableSetOf() + var secret: String? = null + var perms: String? = null + var name: String? = null + var url: String? = null + var image: String? = null + forEachParam(params) { key, value -> + when (key) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + "perms" -> perms = value + "name" -> name = value + "url" -> url = value + "image" -> image = value + } + } + val validSecret = secret ?: return null + return NostrConnect(pubkey.lowercase(), relays, validSecret, perms, name, url, image) + } + + /** Build a `nostrconnect://?relay=…&secret=…&…` offer URI. */ + fun buildNostrConnect( + clientPubKey: HexKey, + relays: Collection, + secret: String, + perms: String? = null, + name: String? = null, + url: String? = null, + image: String? = null, + ): String = + buildString { + append(NOSTRCONNECT_SCHEME).append(clientPubKey) + append('?').append(relays.joinToString("&") { "relay=${encode(it.url)}" }) + append("&secret=").append(encode(secret)) + if (perms != null) append("&perms=").append(encode(perms)) + if (name != null) append("&name=").append(encode(name)) + if (url != null) append("&url=").append(encode(url)) + if (image != null) append("&image=").append(encode(image)) + } + + private fun isValidPubKey(pubkey: String): Boolean = pubkey.length == 64 && Hex.isHex(pubkey) + + /** Splits `?` into the authority and the raw query (empty when no `?`). */ + private fun splitAuthority(rest: String): Pair? { + val parts = rest.split("?", limit = 2) + val authority = parts[0] + if (authority.isEmpty()) return null + return authority to (parts.getOrNull(1) ?: "") + } + + private inline fun forEachParam( + query: String, + action: (key: String, value: String) -> Unit, + ) { + if (query.isEmpty()) return + for (param in query.split("&")) { + val kv = param.split("=", limit = 2) + if (kv.size < 2) continue + action(kv[0], decode(kv[1])) + } + } + + /** Percent-decode a query value (e.g. `wss%3A%2F%2F…` → `wss://…`). */ + fun decode(input: String): String { + if ('%' !in input) return input + val bytes = ArrayList(input.length) + var i = 0 + while (i < input.length) { + val c = input[i] + if (c == '%' && i + 2 < input.length) { + val code = input.substring(i + 1, i + 3).toIntOrNull(16) + if (code != null) { + bytes.add(code.toByte()) + i += 3 + continue + } + } + for (b in c.toString().encodeToByteArray()) bytes.add(b) + i++ + } + return bytes.toByteArray().decodeToString() + } + + /** Percent-encode a query value; only unreserved `A-Za-z0-9-._~` pass through. */ + fun encode(input: String): String { + val sb = StringBuilder(input.length) + for (b in input.encodeToByteArray()) { + val c = (b.toInt() and 0xFF).toChar() + if (c.isLetterOrDigit() && c.code < 128 || c in "-._~") { + sb.append(c) + } else { + sb.append('%').append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) + } + } + return sb.toString() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt new file mode 100644 index 0000000000..f14967e354 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -0,0 +1,147 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite + +/** + * The signer/bunker side of NIP-46: turns a decrypted [BunkerRequest] from a + * client into the [BunkerResponse] the client expects, performing the actual + * crypto with the user's own [signer]. + * + * This is the "NIP-46 processor" — it is deliberately signer-agnostic: [signer] + * can be a local keypair ([NostrSignerInternal]) or an external NIP-55 app + * ([NostrSignerExternal]), and every request is fulfilled through the same + * [NostrSigner] surface (`sign`, `nip04/44Encrypt/Decrypt`). Whichever signer + * the user logged in with is the one that ultimately performs the work. + * + * Authorization is delegated to [authorizer]: signing/encryption/decryption are + * gated, while public/harmless reads (`get_public_key`, `ping`, `get_relays`) + * always succeed. All failures — decryption, authorization, an unsupported + * method, or an exception from the signer — are turned into a + * [BunkerResponseError] carrying the request id, so the client always gets a + * reply it can correlate. + * + * Pairs with [NostrConnectSignerService], which subscribes to the relays, + * decrypts each kind-24133 request, calls [process], and publishes the reply. + */ +class BunkerRequestProcessor( + val signer: NostrSigner, + val relays: suspend () -> Set, + val authorizer: Nip46RequestAuthorizer, +) { + /** + * Fulfils a single decrypted [request] sent by [clientPubKey], returning the + * response to encrypt and send back. Never throws — signer/authorizer errors + * are captured as [BunkerResponseError]. + */ + suspend fun process( + clientPubKey: HexKey, + request: BunkerRequest, + ): BunkerResponse = + try { + when (request) { + is BunkerRequestConnect -> + when (val decision = authorizer.onConnect(clientPubKey, request)) { + is Nip46ConnectDecision.Accept -> BunkerResponse(request.id, decision.ackSecret, null) + is Nip46ConnectDecision.Reject -> BunkerResponseError(request.id, decision.reason) + } + + is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + + is BunkerRequestPing -> BunkerResponsePong(request.id) + + is BunkerRequestGetRelays -> + BunkerResponseGetRelays(request.id, relays().associate { it.url to ReadWrite(read = true, write = true) }) + + is BunkerRequestSign -> + ifAuthorized(clientPubKey, request) { + val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) + BunkerResponseEvent(request.id, signed) + } + + is BunkerRequestNip04Encrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) + } + + is BunkerRequestNip04Decrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) + } + + is BunkerRequestNip44Encrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) + } + + is BunkerRequestNip44Decrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) + } + + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } + } catch (e: Exception) { + BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") + } + + private suspend inline fun ifAuthorized( + clientPubKey: HexKey, + request: BunkerRequest, + block: () -> BunkerResponse, + ): BunkerResponse = + if (authorizer.authorize(clientPubKey, request)) { + block() + } else { + BunkerResponseError(request.id, ERROR_UNAUTHORIZED) + } + + companion object { + /** Ack result for a `connect` request with no secret to echo (mirrors [BunkerResponseAck.RESULT]). */ + const val ACK: String = "ack" + + /** Error result returned when [Nip46RequestAuthorizer.authorize] denies a request. */ + const val ERROR_UNAUTHORIZED: String = "unauthorized" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt new file mode 100644 index 0000000000..126093bdc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt @@ -0,0 +1,81 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect + +/** + * The authorization boundary a NIP-46 signer (a "bunker") consults before it + * performs a request on the user's behalf. It is the protocol-agnostic hook the + * host app uses to plug in *its* permission model — in Amethyst that is the + * shared "Connected Apps" ledger, so the same trust levels that gate napplets + * and web apps also gate remote NIP-46 clients. + * + * [BunkerRequestProcessor] owns the wire logic; this interface owns "may this + * client do this?". Everything here is `suspend` so an implementation may block + * on disk, a live user prompt, or IPC without changing the processor. + * + * Public, harmless requests (`get_public_key`, `ping`, `get_relays`) are NOT + * routed through [authorize]; only signing, encryption and decryption are. + */ +interface Nip46RequestAuthorizer { + /** + * Called when a client sends a `connect` request. The implementation + * validates the offered secret (the `bunker://…?secret=…` pairing token), + * registers the client as a connected app if it accepts, and returns the + * decision. On accept the returned [Nip46ConnectDecision.Accept.ackSecret] + * is echoed to the client (the offered secret, or `"ack"` when none was set). + */ + suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision + + /** + * Called before every signing/encryption/decryption request. Return `true` + * to perform it, `false` to reject the client with an "unauthorized" error. + * Implementations typically map [request] to a per-app permission and read + * the standing grant/deny decision for [clientPubKey]. + */ + suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean +} + +/** The verdict for a NIP-46 `connect` request. */ +sealed class Nip46ConnectDecision { + /** + * Accept the connection. [ackSecret] is echoed back to the client as the + * connect result — the offered secret when one was set (so the client can + * validate it), otherwise `"ack"`. + */ + data class Accept( + val ackSecret: String, + ) : Nip46ConnectDecision() + + /** Reject the connection; [reason] is returned to the client as an error. */ + data class Reject( + val reason: String, + ) : Nip46ConnectDecision() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt new file mode 100644 index 0000000000..a9ed466a9a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -0,0 +1,122 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +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.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED + +/** + * Runs a NIP-46 remote signer ("bunker") for one account: subscribes to the + * given [relays] for kind-24133 requests addressed to the user, decrypts each + * one with the user's [signer], hands it to [processor], and publishes the + * encrypted reply back to the requesting client. + * + * The signer is whatever the account logged in with — a local keypair or a + * NIP-55 external app — so the same [NostrConnectEvent.create] transport + * encryption and the same [BunkerRequestProcessor] dispatch serve both. + * + * [run] is a long-running suspend loop: it services requests until the calling + * coroutine is cancelled, then tears the subscription down. Callers who need to + * follow a changing relay set (e.g. the user editing their inbox relays) should + * cancel and relaunch [run] with the new set. + */ +class NostrConnectSignerService( + val client: INostrClient, + val signer: NostrSigner, + val processor: BunkerRequestProcessor, + val relays: Set, + /** Optional hook, invoked with each serviced request's method + client, for logging/metrics. */ + val onServiced: ((method: String, clientPubKey: String, error: String?) -> Unit)? = null, +) { + /** + * Subscribes and services requests until cancelled. Duplicate events (the + * same request seen on more than one relay) are handled once. Never returns + * normally — it loops until the coroutine is cancelled. + */ + suspend fun run() { + if (relays.isEmpty()) { + Log.w("NIP46Signer") { "no relays to listen on; signer service is idle" } + return + } + + val self = signer.pubKey + val events = Channel(UNLIMITED) + val seen = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent && event.verifiedRecipientPubKey() == self && seen.add(event.id)) { + events.trySend(event) + } + } + } + + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + while (true) { + handle(events.receive()) + } + } finally { + client.unsubscribe(subId) + events.close() + } + } + + private suspend fun handle(event: NostrConnectEvent) { + val client = event.talkingWith(signer.pubKey) + val request = + try { + event.decryptMessage(signer) as? BunkerRequest ?: return + } catch (e: Exception) { + Log.w("NIP46Signer") { "could not decrypt request ${event.id.take(8)}: ${e.message}" } + return + } + + val response = processor.process(client, request) + val error = (response as? BunkerResponseError)?.error + onServiced?.invoke(request.method, client, error) + + try { + val reply = NostrConnectEvent.create(response, client, signer) + this.client.publish(reply, relays) + } catch (e: Exception) { + Log.w("NIP46Signer") { "failed to send reply for ${request.method}: ${e.message}" } + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt new file mode 100644 index 0000000000..30499d4276 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt @@ -0,0 +1,99 @@ +/* + * 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.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NostrConnectURITest { + private val pubkey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + @Test + fun parseBunkerWithPercentEncodedRelay() { + val uri = "bunker://$pubkey?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" + val parsed = NostrConnectURI.parseBunker(uri)!! + assertEquals(pubkey, parsed.remoteSignerPubKey) + assertEquals("abc123", parsed.secret) + assertTrue(parsed.relays.contains(relay)) + } + + @Test + fun parseBunkerRejectsWrongScheme() { + assertNull(NostrConnectURI.parseBunker("nostrconnect://$pubkey?secret=x")) + } + + @Test + fun parseBunkerRejectsBadPubkey() { + assertNull(NostrConnectURI.parseBunker("bunker://not-a-key?secret=x")) + } + + @Test + fun buildBunkerRoundTrips() { + val uri = NostrConnectURI.buildBunker(pubkey, setOf(relay), "s3cr3t") + val parsed = NostrConnectURI.parseBunker(uri)!! + assertEquals(pubkey, parsed.remoteSignerPubKey) + assertEquals("s3cr3t", parsed.secret) + assertTrue(parsed.relays.contains(relay)) + // relay must be percent-encoded in the built URI + assertTrue(uri.contains("relay=wss%3A%2F%2F")) + } + + @Test + fun parseNostrConnectFull() { + val uri = + "nostrconnect://$pubkey?relay=wss%3A%2F%2Frelay.example.com&secret=xyz&perms=sign_event%3A1%2Cnip44_encrypt&name=My%20App" + val parsed = NostrConnectURI.parseNostrConnect(uri)!! + assertEquals(pubkey, parsed.clientPubKey) + assertEquals("xyz", parsed.secret) + assertEquals("sign_event:1,nip44_encrypt", parsed.perms) + assertEquals("My App", parsed.name) + assertTrue(parsed.relays.contains(relay)) + } + + @Test + fun parseNostrConnectRequiresSecret() { + assertNull(NostrConnectURI.parseNostrConnect("nostrconnect://$pubkey?relay=wss%3A%2F%2Frelay.example.com")) + } + + @Test + fun nostrConnectRoundTrips() { + val uri = NostrConnectURI.buildNostrConnect(pubkey, setOf(relay), "sec", perms = "sign_event:1", name = "Amethyst") + val parsed = NostrConnectURI.parseNostrConnect(uri)!! + assertEquals(pubkey, parsed.clientPubKey) + assertEquals("sec", parsed.secret) + assertEquals("sign_event:1", parsed.perms) + assertEquals("Amethyst", parsed.name) + } + + @Test + fun decodeHandlesUtf8() { + assertEquals("café", NostrConnectURI.decode("caf%C3%A9")) + } + + @Test + fun encodeLeavesUnreservedUntouched() { + assertEquals("abcXYZ-._~", NostrConnectURI.encode("abcXYZ-._~")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt new file mode 100644 index 0000000000..ceffdd2f65 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt @@ -0,0 +1,313 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pure dispatch/authorization tests for the NIP-46 signer core. Uses a canned + * [FakeSigner] so no secp256k1/NIP-44 crypto is required — these run in + * commonTest on every platform. + */ +class BunkerRequestProcessorTest { + private val userPubKey = "a".repeat(64) + private val clientPubKey = "c".repeat(64) + + /** Records what the signer was asked to do and returns fixed values. */ + private class FakeSigner( + pubKey: HexKey, + ) : NostrSigner(pubKey) { + var signCount = 0 + var nip44EncryptCount = 0 + var nip44DecryptCount = 0 + + val cannedEvent = + Event( + id = "e".repeat(64), + pubKey = pubKey, + createdAt = 1L, + kind = 1, + tags = emptyArray(), + content = "signed", + sig = "f".repeat(128), + ) + + override fun isWriteable() = true + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + signCount++ + return cannedEvent as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "nip04:$plaintext" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "nip04dec:$ciphertext" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + nip44EncryptCount++ + return "nip44:$plaintext" + } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + nip44DecryptCount++ + return "nip44dec:$ciphertext" + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** Configurable authorizer for the tests. */ + private class FakeAuthorizer( + val connectDecision: Nip46ConnectDecision, + val allow: Boolean, + ) : Nip46RequestAuthorizer { + var connectCalls = 0 + var authorizeCalls = 0 + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision { + connectCalls++ + return connectDecision + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean { + authorizeCalls++ + return allow + } + } + + private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun processor( + signer: FakeSigner = FakeSigner(userPubKey), + authorizer: FakeAuthorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true), + ) = BunkerRequestProcessor(signer, { setOf(relay) }, authorizer) + + @Test + fun getPublicKeyReturnsUserPubKeyWithoutAuthorization() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestGetPublicKey("1")) + + assertTrue(res is BunkerResponsePublicKey) + assertEquals(userPubKey, res.pubkey) + assertEquals("1", res.id) + // public reads are never gated + assertEquals(0, authorizer.authorizeCalls) + } + + @Test + fun pingReturnsPong() = + runTest { + val res = processor().process(clientPubKey, BunkerRequestPing("2")) + assertTrue(res is BunkerResponsePong) + assertEquals("2", res.id) + } + + @Test + fun getRelaysReturnsConfiguredRelays() = + runTest { + val res = processor().process(clientPubKey, BunkerRequestGetRelays("3")) + assertTrue(res is BunkerResponseGetRelays) + assertTrue(res.relays.containsKey(relay.url)) + } + + @Test + fun connectAcceptEchoesSecret() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("s3cr3t"), allow = true) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "4", remoteKey = userPubKey, secret = "s3cr3t")) + + assertEquals(1, authorizer.connectCalls) + assertEquals("4", res.id) + assertEquals("s3cr3t", res.result) + } + + @Test + fun connectRejectReturnsError() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Reject("invalid secret"), allow = true) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "5", remoteKey = userPubKey, secret = "wrong")) + + assertTrue(res is BunkerResponseError) + assertEquals("invalid secret", res.error) + } + + @Test + fun signAuthorizedSignsWithUserSigner() = + runTest { + val signer = FakeSigner(userPubKey) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer).process(clientPubKey, BunkerRequestSign("6", template)) + + assertTrue(res is BunkerResponseEvent) + assertEquals(1, signer.signCount) + assertEquals(signer.cannedEvent.id, res.event.id) + } + + @Test + fun signDeniedReturnsUnauthorized() = + runTest { + val signer = FakeSigner(userPubKey) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestSign("7", template)) + + assertTrue(res is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, res.error) + assertEquals(0, signer.signCount) + } + + @Test + fun nip44EncryptAuthorized() = + runTest { + val signer = FakeSigner(userPubKey) + val res = processor(signer = signer).process(clientPubKey, BunkerRequestNip44Encrypt("8", clientPubKey, "hello")) + + assertTrue(res is BunkerResponseEncrypt) + assertEquals("nip44:hello", res.ciphertext) + assertEquals(1, signer.nip44EncryptCount) + } + + @Test + fun nip44DecryptDeniedDoesNotCallSigner() = + runTest { + val signer = FakeSigner(userPubKey) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestNip44Decrypt("9", clientPubKey, "ct")) + + assertTrue(res is BunkerResponseError) + assertEquals(0, signer.nip44DecryptCount) + } + + @Test + fun unsupportedMethodReturnsError() = + runTest { + val res = processor().process(clientPubKey, BunkerRequest("10", "made_up_method", emptyArray())) + assertTrue(res is BunkerResponseError) + assertTrue(res.error!!.contains("made_up_method")) + } + + @Test + fun signerExceptionBecomesErrorResponse() = + runTest { + val throwing = + object : NostrSigner(userPubKey) { + override fun isWriteable() = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = throw IllegalStateException("boom") + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = + BunkerRequestProcessor(throwing, { setOf(relay) }, FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true)) + .process(clientPubKey, BunkerRequestSign("11", template)) + + assertTrue(res is BunkerResponseError) + assertTrue(res.error!!.contains("boom")) + } +}