feat(quartz): add signer-side NIP-46 processor, service and URI codec

Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:

- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
  BunkerResponse the client expects, performing the work through whatever
  NostrSigner the account uses (local keypair or NIP-55 external app).
  Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
  public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
  own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
  set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
  pairing URIs (percent-encoded), shared by CLI/desktop/Android.

Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:20 +00:00
parent 3d99f10058
commit b1227fa7cb
6 changed files with 955 additions and 0 deletions
@@ -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://<remote-signer-pubkey>?relay=…&secret=…` — the **signer**
* advertises how to reach it (Amethyst mints this when it acts as a bunker).
* - `nostrconnect://<client-pubkey>?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<NormalizedRelayUrl>,
val secret: String?,
)
/** A parsed `nostrconnect://` client offer. */
data class NostrConnect(
val clientPubKey: HexKey,
val relays: Set<NormalizedRelayUrl>,
val secret: String,
val perms: String? = null,
val name: String? = null,
val url: String? = null,
val image: String? = null,
)
/** Parse a `bunker://<pubkey>?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<NormalizedRelayUrl>()
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://<pubkey>?relay=…&secret=…` advertisement URI. */
fun buildBunker(
remoteSignerPubKey: HexKey,
relays: Collection<NormalizedRelayUrl>,
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://<client-pubkey>?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<NormalizedRelayUrl>()
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://<client-pubkey>?relay=…&secret=…&…` offer URI. */
fun buildNostrConnect(
clientPubKey: HexKey,
relays: Collection<NormalizedRelayUrl>,
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 `<authority>?<query>` into the authority and the raw query (empty when no `?`). */
private fun splitAuthority(rest: String): Pair<String, String>? {
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<Byte>(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()
}
}
@@ -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<NormalizedRelayUrl>,
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<Event>(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"
}
}
@@ -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()
}
@@ -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<NormalizedRelayUrl>,
/** 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<NostrConnectEvent>(UNLIMITED)
val seen = mutableSetOf<String>()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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}" }
}
}
}
@@ -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-._~"))
}
}
@@ -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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Event>(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<Event>(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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Event>(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"))
}
}