mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+193
@@ -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()
|
||||
}
|
||||
}
|
||||
+147
@@ -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"
|
||||
}
|
||||
}
|
||||
+81
@@ -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()
|
||||
}
|
||||
+122
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -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-._~"))
|
||||
}
|
||||
}
|
||||
+313
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user