diff --git a/quartz-relay/build.gradle.kts b/quartz-relay/build.gradle.kts index 1c93fc035..0585a8a70 100644 --- a/quartz-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.jackson.module.kotlin) + implementation(libs.kotlinx.serialization.json) // Bundled SQLite driver — Relay's default in-memory EventStore creates // an in-memory DB at runtime. diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index a31532117..4e0134d03 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -60,3 +60,11 @@ require_auth = false # pubkey_blacklist = [] # kind_whitelist = [0, 1, 3, 7, 1059, 30023] # kind_blacklist = [4] + +[admin] +# NIP-86 relay management API. When `pubkeys` is non-empty, the relay +# accepts HTTP POST application/nostr+json+rpc on the same URL, +# authenticated with NIP-98 HTTP-Auth. Only events signed by one of +# the listed pubkeys can run admin RPCs (banpubkey / banevent / +# changerelayname / …). Empty (the default) disables the endpoint. +# pubkeys = ["abcdef...64hex..."] diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index 8d773827c..416f83843 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -20,8 +20,14 @@ */ package com.vitorpamplona.quartz.relay +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.relay.admin.Nip86Server +import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -30,11 +36,14 @@ import io.ktor.server.cio.CIO import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.embeddedServer import io.ktor.server.request.header +import io.ktor.server.request.receiveChannel import io.ktor.server.response.respondText import io.ktor.server.routing.get +import io.ktor.server.routing.post import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.webSocket +import io.ktor.utils.io.toByteArray import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.consumeEach @@ -72,7 +81,32 @@ class LocalRelayServer( * uses Ktor's default (~1 MiB). */ val maxFrameBytes: Long? = null, + /** + * Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default) + * disables the admin endpoint entirely — POSTs return 403. + * Otherwise: HTTP POSTs to [path] with `Content-Type: + * application/nostr+json+rpc` are dispatched to [Nip86Server], + * gated by NIP-98 HTTP-Auth membership in this set. + */ + val adminPubkeys: Set = emptySet(), ) { + /** + * Bridges the relay's mutable [RelayInfo] to [Nip86Server.InfoHolder] + * so admin RPCs can rewrite the NIP-11 doc atomically. + */ + private val infoHolder = + object : Nip86Server.InfoHolder { + override fun get() = relay.info + + override fun set(info: RelayInfo) { + relay.updateInfo { info.document } + } + } + + private val nip86 = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store) + private val nip98 = Nip98AuthVerifier() + + private val adminAllowList: Set = adminPubkeys.mapTo(HashSet()) { it.lowercase() } private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -126,6 +160,11 @@ class LocalRelayServer( ) } } + // NIP-86: POST application/nostr+json+rpc with a NIP-98 + // signed Authorization header → JSON-RPC dispatch. + post(path) { + handleNip86(call) + } webSocket(path) { val session = relay.server.connect { json -> @@ -190,6 +229,94 @@ class LocalRelayServer( resolvedPort = -1 } + /** + * Handles a NIP-86 admin RPC request: + * 1. 403 if no admin pubkey list is configured (endpoint disabled). + * 2. 401 if the NIP-98 Authorization header is missing/invalid. + * 3. 403 if the verified pubkey isn't in [adminAllowList]. + * 4. 400 if the body isn't a valid Nip86Request. + * 5. 200 with a Nip86Response JSON body otherwise. + * + * `application/nostr+json+rpc` is the wire content type prescribed + * by NIP-86; we send it on responses and accept any body on the + * request (the auth event's payload-hash already binds the body). + */ + private suspend fun handleNip86(call: io.ktor.server.application.ApplicationCall) { + if (adminAllowList.isEmpty()) { + call.respondText( + "NIP-86 management API is not enabled on this relay.", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + return + } + + val body = call.receiveChannel().toByteArray() + val authHeader = call.request.header(HttpHeaders.Authorization) + // NIP-86 spec: the URL the client signed must be the relay's + // canonical http(s) URL, not the WS one. We reconstruct it from + // the request so the comparison is symmetric whether the + // operator runs the relay behind a reverse proxy or directly. + val signedUrl = + "http://" + + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path + val verification = nip98.verify(authHeader, method = "POST", url = signedUrl, body = body) + + val pubkey = + when (verification) { + is Nip98AuthVerifier.Result.Verified -> { + verification.pubkey + } + + Nip98AuthVerifier.Result.Missing -> { + call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim()) + call.respondText( + "missing Authorization header (NIP-98)", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + return + } + + is Nip98AuthVerifier.Result.Malformed -> { + call.respondText( + "invalid NIP-98 Authorization: ${verification.reason}", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + return + } + } + + if (pubkey.lowercase() !in adminAllowList) { + call.respondText( + "pubkey is not on the admin list", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + return + } + + val req = + try { + JsonMapper.fromJson(body.decodeToString()) + } catch (e: Exception) { + call.respondText( + "invalid Nip86Request: ${e.message ?: e::class.simpleName}", + ContentType.Text.Plain, + HttpStatusCode.BadRequest, + ) + return + } + + val response: Nip86Response = nip86.dispatch(req) + call.respondText( + JsonMapper.toJson(response), + ContentType.parse("application/nostr+json+rpc"), + HttpStatusCode.OK, + ) + } + /** * Best-effort NOTICE to every active client. Failures are * swallowed — a flaky socket on its way out is exactly the case diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt index 838f8e9a0..e0dc801cf 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt @@ -114,6 +114,7 @@ fun main(args: Array) { port = port, path = path, maxFrameBytes = frameLimit, + adminPubkeys = config.admin.pubkeys.toSet(), ).start() Runtime.getRuntime().addShutdownHook( diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index 9df98315c..b6e155005 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.relay.admin.BanStore +import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy import kotlinx.coroutines.SupervisorJob import kotlin.coroutines.CoroutineContext @@ -51,11 +54,45 @@ import kotlin.coroutines.CoroutineContext class Relay( val url: NormalizedRelayUrl, val store: IEventStore = EventStore(dbName = null, relay = url), - val info: RelayInfo = RelayInfo.default(url), + info: RelayInfo = RelayInfo.default(url), policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, parentContext: CoroutineContext = SupervisorJob(), ) : AutoCloseable { - val server = NostrServer(store, policyBuilder, parentContext) + /** + * NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`, + * `changerelaydescription`, `changerelayicon`) can swap the doc + * atomically. Readers (the NIP-11 GET endpoint) re-read on every + * request so changes are visible immediately, no restart needed. + */ + @Volatile + var info: RelayInfo = info + private set + + /** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */ + fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { + info = RelayInfo(transform(info.document)) + } + + /** + * Runtime-mutable ban / allow lists. NIP-86 RPC handlers in + * [admin.Nip86Server] mutate this; the policy stack consults it on + * every accept call via [DynamicBanPolicy]. + */ + val banStore: BanStore = BanStore() + + val server = + NostrServer( + store, + // Always prepend a DynamicBanPolicy so NIP-86 admin actions + // bite. When the operator-supplied builder returns + // [EmptyPolicy] we use the dynamic policy alone; otherwise + // we stack them so both layers must accept. + policyBuilder = { + val user = policyBuilder() + if (user === EmptyPolicy) DynamicBanPolicy(banStore) else user + DynamicBanPolicy(banStore) + }, + parentContext, + ) /** * Inserts events directly into the underlying store, bypassing the wire protocol. diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt index 7e49e6cfe..87884f284 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt @@ -49,8 +49,9 @@ data class RelayInfo( // Currently implemented: NIP-01 (basic), NIP-09 (deletion via // DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration // via ExpirationModule), NIP-42 (AUTH — when policy enables), - // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish). - supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62"), + // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish), + // NIP-86 (relay management API — when admin pubkeys are configured). + supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"), ), ) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt new file mode 100644 index 000000000..9ef38df27 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt @@ -0,0 +1,144 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import java.util.concurrent.ConcurrentHashMap + +/** + * Mutable, thread-safe runtime state for the NIP-86 management API. + * + * Each entry carries an optional reason string so list-* RPCs can echo + * back why an admin took the action — useful for audit trails. + * + * Today the state is in-memory only; a process restart wipes the bans. + * Wiring a persistent backend is a separate concern (a JSON file + * snapshot on each mutation, or a small SQLite table) and can be + * layered on by replacing this class behind the [DynamicBanPolicy] + * interface. + */ +class BanStore { + /** + * Pubkeys whose events the relay rejects. Compared case-insensitive + * (lowercased on insert / lookup) so an admin pasting a hex pubkey + * with mixed case still works. Empty-string value means "no + * reason given" — `ConcurrentHashMap` rejects nulls. + */ + private val bannedPubkeys = ConcurrentHashMap() + + /** + * Pubkeys explicitly allowed. When non-empty, this acts as a + * whitelist: events from any pubkey not on the list are rejected. + */ + private val allowedPubkeys = ConcurrentHashMap() + + /** Event ids the relay refuses to store/replay. */ + private val bannedEventIds = ConcurrentHashMap() + + private fun reasonOrEmpty(s: String?): String = s ?: "" + + private fun nullIfEmpty(s: String): String? = s.ifEmpty { null } + + /** + * Allowed kinds. When non-empty, events whose kind is not in the + * list are rejected. + */ + private val allowedKinds = ConcurrentHashMap.newKeySet() + + /** Disallowed kinds. Always blocks regardless of [allowedKinds]. */ + private val disallowedKinds = ConcurrentHashMap.newKeySet() + + // -- Pubkey ban list ----------------------------------------------------- + + fun banPubkey( + pubkey: HexKey, + reason: String? = null, + ) { + bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) + } + + fun unbanPubkey(pubkey: HexKey) { + bannedPubkeys.remove(pubkey.lowercase()) + } + + fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase()) + + fun listBannedPubkeys(): List> = bannedPubkeys.entries.map { it.key to nullIfEmpty(it.value) } + + // -- Pubkey allow list --------------------------------------------------- + + fun allowPubkey( + pubkey: HexKey, + reason: String? = null, + ) { + allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) + } + + fun unallowPubkey(pubkey: HexKey) { + allowedPubkeys.remove(pubkey.lowercase()) + } + + fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase()) + + fun listAllowedPubkeys(): List> = allowedPubkeys.entries.map { it.key to nullIfEmpty(it.value) } + + fun hasAllowList(): Boolean = allowedPubkeys.isNotEmpty() + + // -- Event id ban list --------------------------------------------------- + + fun banEvent( + eventId: HexKey, + reason: String? = null, + ) { + bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason) + } + + /** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */ + fun allowEvent(eventId: HexKey) { + bannedEventIds.remove(eventId.lowercase()) + } + + fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase()) + + fun listBannedEvents(): List> = bannedEventIds.entries.map { it.key to nullIfEmpty(it.value) } + + // -- Kind allow / deny -------------------------------------------------- + + fun allowKind(kind: Int) { + allowedKinds.add(kind) + } + + fun disallowKind(kind: Int) { + disallowedKinds.add(kind) + // Disallowing a kind implicitly removes it from the allow list. + allowedKinds.remove(kind) + } + + fun listAllowedKinds(): List = allowedKinds.sorted() + + fun listDisallowedKinds(): List = disallowedKinds.sorted() + + fun isKindAllowed(kind: Int): Boolean { + if (kind in disallowedKinds) return false + if (allowedKinds.isEmpty()) return true + return kind in allowedKinds + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt new file mode 100644 index 000000000..9bc1aa489 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt @@ -0,0 +1,59 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.relay.policies.PassThroughPolicy + +/** + * Reads the live [BanStore] on every EVENT and rejects events that + * violate any of: banned-event-id, banned-pubkey, missing from a + * non-empty allow list, or kind disallowed / not in the kind allow + * list. + * + * This is the runtime-mutable counterpart of the static + * [com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy] + + * [com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy] — + * both sets compose: the event must clear both layers. NIP-86 admin + * RPC mutations land here; the static policies stay frozen at + * boot-time config values. + */ +class DynamicBanPolicy( + val banStore: BanStore, +) : PassThroughPolicy() { + override fun accept(cmd: EventCmd): PolicyResult { + val ev = cmd.event + if (banStore.isBannedEvent(ev.id)) { + return PolicyResult.Rejected("blocked: event id is banned") + } + if (banStore.isBanned(ev.pubKey)) { + return PolicyResult.Rejected("blocked: pubkey is banned") + } + if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) { + return PolicyResult.Rejected("blocked: pubkey is not on the allow list") + } + if (!banStore.isKindAllowed(ev.kind)) { + return PolicyResult.Rejected("blocked: kind ${ev.kind} not allowed") + } + return PolicyResult.Accepted(cmd) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt new file mode 100644 index 000000000..2f1eccbc6 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt @@ -0,0 +1,272 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.relay.RelayInfo +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.int + +/** + * NIP-86 RPC dispatcher. Holds the [BanStore] (mutated by ban/allow + * methods), the live [RelayInfo] handle (mutated by `changerelay*` + * methods, which atomically swap the doc), and the underlying + * [IEventStore] so `banevent` can also delete the offending event. + * + * The dispatcher is transport-agnostic — `LocalRelayServer` calls + * [dispatch] from its HTTP route, but the same handler also works for + * in-process tests that build a [Nip86Request] directly. + * + * [supportedMethods] is the canonical list this server actually + * implements; methods returned outside of it are no-ops and a NIP-86 + * client must not advertise them. + */ +class Nip86Server( + val banStore: BanStore, + /** + * Read-write access to the relay's NIP-11 info doc. The dispatcher + * mutates this when an admin calls `changerelayname` / + * `changerelaydescription` / `changerelayicon`. Relay code reading + * the doc (e.g. the NIP-11 endpoint) must consult this object on + * every request, not cache it. + */ + private val infoHolder: InfoHolder, + private val store: IEventStore? = null, +) { + /** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */ + interface InfoHolder { + fun get(): RelayInfo + + fun set(info: RelayInfo) + } + + val supportedMethods: List = + listOf( + Nip86Method.SUPPORTED_METHODS, + Nip86Method.BAN_PUBKEY, + Nip86Method.UNBAN_PUBKEY, + Nip86Method.LIST_BANNED_PUBKEYS, + Nip86Method.ALLOW_PUBKEY, + Nip86Method.UNALLOW_PUBKEY, + Nip86Method.LIST_ALLOWED_PUBKEYS, + Nip86Method.BAN_EVENT, + Nip86Method.ALLOW_EVENT, + Nip86Method.LIST_BANNED_EVENTS, + Nip86Method.ALLOW_KIND, + Nip86Method.DISALLOW_KIND, + Nip86Method.LIST_ALLOWED_KINDS, + Nip86Method.CHANGE_RELAY_NAME, + Nip86Method.CHANGE_RELAY_DESCRIPTION, + Nip86Method.CHANGE_RELAY_ICON, + ) + + /** + * Dispatches a single RPC request. Synchronous-looking but does + * suspend internally for the `banevent` event-store delete path. + */ + suspend fun dispatch(req: Nip86Request): Nip86Response = + runCatching { + when (req.method) { + Nip86Method.SUPPORTED_METHODS -> { + result(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } }) + } + + Nip86Method.BAN_PUBKEY -> { + val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") + banStore.banPubkey(pk, reason) + result(JsonPrimitive(true)) + } + + Nip86Method.UNBAN_PUBKEY -> { + val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") + banStore.unbanPubkey(pk) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_BANNED_PUBKEYS -> { + result( + banStore + .listBannedPubkeys() + .map { (pk, r) -> BannedPubkey(pk, r) } + .toJsonArray(BannedPubkey.serializer()), + ) + } + + Nip86Method.ALLOW_PUBKEY -> { + val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") + banStore.allowPubkey(pk, reason) + result(JsonPrimitive(true)) + } + + Nip86Method.UNALLOW_PUBKEY -> { + val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") + banStore.unallowPubkey(pk) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_ALLOWED_PUBKEYS -> { + result( + banStore + .listAllowedPubkeys() + .map { (pk, r) -> AllowedPubkey(pk, r) } + .toJsonArray(AllowedPubkey.serializer()), + ) + } + + Nip86Method.BAN_EVENT -> { + val (id, reason) = req.params.stringPair() ?: return malformed("expected [event_id, reason?]") + banStore.banEvent(id, reason) + // Also remove the event from the store if it's there. + store?.delete(Filter(ids = listOf(id))) + result(JsonPrimitive(true)) + } + + Nip86Method.ALLOW_EVENT -> { + val (id, _) = req.params.stringPair() ?: return malformed("expected [event_id]") + banStore.allowEvent(id) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_BANNED_EVENTS -> { + result( + banStore + .listBannedEvents() + .map { (id, r) -> BannedEvent(id, r) } + .toJsonArray(BannedEvent.serializer()), + ) + } + + Nip86Method.ALLOW_KIND -> { + val k = req.params.firstInt() ?: return malformed("expected [kind]") + banStore.allowKind(k) + result(JsonPrimitive(true)) + } + + Nip86Method.DISALLOW_KIND -> { + val k = req.params.firstInt() ?: return malformed("expected [kind]") + banStore.disallowKind(k) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_ALLOWED_KINDS -> { + result(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } }) + } + + Nip86Method.CHANGE_RELAY_NAME -> { + val name = req.params.firstString() ?: return malformed("expected [name]") + rewriteInfo { it.copy(name = name) } + result(JsonPrimitive(true)) + } + + Nip86Method.CHANGE_RELAY_DESCRIPTION -> { + val desc = req.params.firstString() ?: return malformed("expected [description]") + rewriteInfo { it.copy(description = desc) } + result(JsonPrimitive(true)) + } + + Nip86Method.CHANGE_RELAY_ICON -> { + val icon = req.params.firstString() ?: return malformed("expected [icon_url]") + rewriteInfo { it.copy(icon = icon) } + result(JsonPrimitive(true)) + } + + else -> { + Nip86Response(error = "method not supported: ${req.method}") + } + } + }.getOrElse { e -> + Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}") + } + + private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { + val current = infoHolder.get().document + infoHolder.set(RelayInfo(transform(current))) + } + + /** [Nip11RelayInformation] is not a `data class`; do a manual field-by-field copy. */ + private fun Nip11RelayInformation.copy( + name: String? = this.name, + description: String? = this.description, + icon: String? = this.icon, + ) = Nip11RelayInformation( + id = this.id, + name = name, + description = description, + icon = icon, + pubkey = this.pubkey, + self = this.self, + contact = this.contact, + supported_nips = this.supported_nips, + supported_nip_extensions = this.supported_nip_extensions, + software = this.software, + version = this.version, + limitation = this.limitation, + relay_countries = this.relay_countries, + language_tags = this.language_tags, + tags = this.tags, + posting_policy = this.posting_policy, + privacy_policy = this.privacy_policy, + terms_of_service = this.terms_of_service, + payments_url = this.payments_url, + retention = this.retention, + fees = this.fees, + nip50 = this.nip50, + supported_grasps = this.supported_grasps, + ) +} + +private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason") + +private fun result(j: JsonElement) = Nip86Response(result = j, error = null) + +private val rpcJson = Json { encodeDefaults = false } + +private fun List.toJsonArray(serializer: KSerializer): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this) + +private fun JsonArray.stringPair(): Pair? { + val first = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() ?: return null + val second = (getOrNull(1) as? JsonPrimitive)?.contentOrNull() + return first to second +} + +private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() + +private fun JsonArray.firstInt(): Int? = + runCatching { + (this[0] as? JsonPrimitive)?.int + }.getOrNull() + +private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt new file mode 100644 index 000000000..1ce0aa72a --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt @@ -0,0 +1,131 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.abs + +/** + * Verifies a NIP-98 `Authorization: Nostr ` header. + * + * NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies) + * `payload` tags. The relay must check: + * 1. Header is `Nostr `. + * 2. Decoded body is a kind-27235 event with a valid Schnorr signature. + * 3. The event's `created_at` is within ±60 s of now (NIP-98 spec). + * 4. The `method` tag matches the HTTP method. + * 5. The `u` tag matches the requested URL. + * 6. If a body is present, the `payload` tag matches `sha256(body)` hex. + * + * Returns the verified pubkey on success; `null` on any failure (the + * caller turns this into a `401 Unauthorized`). + */ +class Nip98AuthVerifier( + private val now: () -> Long = { TimeUtils.now() }, + /** Allowed clock skew in seconds. NIP-98 says 60. */ + private val toleranceSeconds: Long = 60, +) { + @OptIn(ExperimentalEncodingApi::class) + fun verify( + authorizationHeader: String?, + method: String, + url: String, + body: ByteArray?, + ): Result { + if (authorizationHeader.isNullOrBlank()) return Result.Missing + if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME ' header") + + val token = authorizationHeader.substring(SCHEME.length).trim() + val json = + try { + Base64.decode(token).decodeToString() + } catch (_: IllegalArgumentException) { + return Result.Malformed("token is not valid base64") + } + + val event = + try { + OptimizedJsonMapper.fromJson(json) + } catch (_: Exception) { + return Result.Malformed("token does not decode to a Nostr event") + } + + if (event.kind != HTTPAuthorizationEvent.KIND) { + return Result.Malformed("event kind ${event.kind} != ${HTTPAuthorizationEvent.KIND}") + } + if (!event.verify()) return Result.Malformed("bad event signature or id") + + val skew = abs(event.createdAt - now()) + if (skew > toleranceSeconds) { + return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)") + } + + // Re-wrap as the typed event so the tag accessors work. + val auth = + HTTPAuthorizationEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + + if (!auth.method().equals(method, ignoreCase = true)) { + return Result.Malformed("method mismatch: expected $method, got ${auth.method()}") + } + if (auth.url() != url) { + return Result.Malformed("url mismatch: expected $url, got ${auth.url()}") + } + if (body != null && body.isNotEmpty()) { + val expected = sha256(body).toHexKey() + if (auth.payloadHash() != expected) { + return Result.Malformed("payload hash mismatch") + } + } + + return Result.Verified(event.pubKey) + } + + sealed interface Result { + data class Verified( + val pubkey: HexKey, + ) : Result + + object Missing : Result + + data class Malformed( + val reason: String, + ) : Result + } + + companion object { + const val SCHEME = "Nostr " + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt index 5e2cd78f8..4ba2bcb50 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt @@ -42,6 +42,7 @@ data class RelayConfig( val options: OptionsSection = OptionsSection(), val limits: LimitsSection = LimitsSection(), val authorization: AuthorizationSection = AuthorizationSection(), + val admin: AdminSection = AdminSection(), ) { /** * Maps the `[info]` section into a [RelayInfo] used by the NIP-11 @@ -133,6 +134,17 @@ data class RelayConfig( val kind_blacklist: List = emptyList(), ) + /** + * NIP-86 relay management API. When [pubkeys] is non-empty, + * `LocalRelayServer` exposes a POST endpoint at the relay path + * that accepts JSON-RPC admin requests authenticated via NIP-98 + * HTTP-Auth. Only requests signed by one of these pubkeys are + * dispatched. + */ + data class AdminSection( + val pubkeys: List = emptyList(), + ) + companion object { private val mapper = tomlMapper { } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt new file mode 100644 index 000000000..3b85c82aa --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt @@ -0,0 +1,96 @@ +/* + * 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.relay.admin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BanStoreTest { + @Test + fun pubkeyBanIsCaseInsensitive() { + val s = BanStore() + s.banPubkey("ABCDEF1234".padEnd(64, '0'), "spam") + assertTrue(s.isBanned("abcdef1234".padEnd(64, '0'))) + s.unbanPubkey("abcdef1234".padEnd(64, '0')) + assertFalse(s.isBanned("ABCDEF1234".padEnd(64, '0'))) + } + + @Test + fun allowListEmptyMeansEveryoneAllowed() { + val s = BanStore() + assertFalse(s.hasAllowList()) + // No allow list → policy decision is purely deny-based; the + // store doesn't say a pubkey IS allowed unless it's listed. + assertFalse(s.isAllowedPubkey("aaaa".padEnd(64, '0'))) + } + + @Test + fun allowListNonEmptyTracksMembers() { + val s = BanStore() + s.allowPubkey("aa".padEnd(64, '0'), "trusted") + assertTrue(s.hasAllowList()) + assertTrue(s.isAllowedPubkey("aa".padEnd(64, '0'))) + assertFalse(s.isAllowedPubkey("bb".padEnd(64, '0'))) + s.unallowPubkey("aa".padEnd(64, '0')) + assertFalse(s.hasAllowList()) + } + + @Test + fun eventBanRoundTrip() { + val s = BanStore() + s.banEvent("ee".padEnd(64, '0'), "policy") + assertTrue(s.isBannedEvent("EE".padEnd(64, '0'))) + s.allowEvent("ee".padEnd(64, '0')) + assertFalse(s.isBannedEvent("ee".padEnd(64, '0'))) + } + + @Test + fun kindAllowDenyRules() { + val s = BanStore() + // Empty allow + empty deny → every kind is allowed. + assertTrue(s.isKindAllowed(1)) + + s.allowKind(1) + s.allowKind(7) + // Allow non-empty → only listed kinds are allowed. + assertTrue(s.isKindAllowed(1)) + assertFalse(s.isKindAllowed(4)) + + s.disallowKind(7) + // Disallowing a kind removes it from the allow list and blocks. + assertFalse(s.isKindAllowed(7)) + assertTrue(s.isKindAllowed(1)) + assertEquals(listOf(1), s.listAllowedKinds()) + assertEquals(listOf(7), s.listDisallowedKinds()) + } + + @Test + fun listsReflectStateForAuditTrail() { + val s = BanStore() + s.banPubkey("aa".padEnd(64, '0'), "spam") + s.banPubkey("bb".padEnd(64, '0'), null) + val banned = s.listBannedPubkeys().toMap() + assertEquals("spam", banned["aa".padEnd(64, '0')]) + assertEquals(null, banned["bb".padEnd(64, '0')]) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt new file mode 100644 index 000000000..0aabacd55 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt @@ -0,0 +1,232 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.relay.LocalRelayServer +import com.vitorpamplona.quartz.relay.Relay +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Drives a real `LocalRelayServer` over HTTP and proves the NIP-86 + * admin RPC flow works end-to-end: NIP-98 auth, admin allow-list + * gate, ban mutation, and the resulting policy effect on a follow-up + * EVENT publish. + */ +class Nip86EndToEndTest { + private lateinit var relay: Relay + private lateinit var server: LocalRelayServer + private lateinit var scope: CoroutineScope + private lateinit var nostrClient: NostrClient + + private val httpClient = OkHttpClient.Builder().build() + + private val admin = NostrSignerSync(KeyPair()) + private val outsider = NostrSignerSync(KeyPair()) + private val targetUser = NostrSignerSync(KeyPair()) + + @BeforeTest + fun setup() { + val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() + relay = Relay(url = placeholder) + server = + LocalRelayServer( + relay = relay, + host = "127.0.0.1", + port = 0, + adminPubkeys = setOf(admin.pubKey), + ).start() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient } + nostrClient = NostrClient(builder, scope) + } + + @AfterTest + fun teardown() { + nostrClient.disconnect() + scope.cancel() + server.stop(gracePeriodMillis = 200, timeoutMillis = 500) + relay.close() + } + + private val httpUrl get() = server.url.replace("ws://", "http://") + + /** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */ + private fun rpc( + request: Nip86Request, + signer: NostrSignerSync, + ): okhttp3.Response { + val body = JsonMapper.toJson(request).encodeToByteArray() + val authTemplate = + HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body) + val authToken = signer.sign(authTemplate).toAuthToken() + return httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .post(body.toRequestBody("application/nostr+json+rpc".toMediaType())) + .header("Authorization", authToken) + .build(), + ).execute() + } + + @Test + fun supportedMethodsListsTheServersMethods() { + rpc(Nip86Request.supportedMethods(), admin).use { + assertEquals(200, it.code) + val json = JsonMapper.fromJson(it.body.string()) + val arr = json.result as JsonArray + val names = arr.map { e -> e.jsonPrimitive.content } + assertTrue(names.contains("supportedmethods")) + assertTrue(names.contains("banpubkey")) + } + } + + @Test + fun foreignSignerReturns403() { + rpc(Nip86Request.supportedMethods(), outsider).use { + assertEquals(403, it.code) + } + } + + @Test + fun missingAuthHeaderReturns401() { + val body = JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray() + httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .post(body.toRequestBody("application/nostr+json+rpc".toMediaType())) + .build(), + ).execute() + .use { + assertEquals(401, it.code) + assertTrue(it.headers["WWW-Authenticate"]?.startsWith("Nostr") == true) + } + } + + @Test + fun banPubkeyBlocksSubsequentEventsFromThatAuthor() = + runBlocking { + val relayUrl = server.url.normalizeRelayUrl() + + // Baseline: targetUser can publish. + val before = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("first")), setOf(relayUrl)) + assertEquals(true, before) + + // Admin bans them. + rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { + assertEquals(200, it.code) + val resp = JsonMapper.fromJson(it.body.string()) + assertEquals(true, (resp.result as JsonPrimitive).boolean) + } + + // Subsequent EVENT from the banned author is rejected. + val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl)) + assertEquals(false, after, "DynamicBanPolicy must reject events from banned pubkeys") + } + + @Test + fun changeRelayNameFlowsToNip11Endpoint() { + rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use { + assertEquals(200, it.code) + } + + // Read the NIP-11 endpoint and confirm the new name is live. + val response = + httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .header("Accept", "application/nostr+json") + .build(), + ).execute() + response.use { + val info = Nip11RelayInformation.fromJson(it.body.string()) + assertEquals("renamed-by-admin", info.name) + } + } + + @Test + fun adminEndpointDisabledWhenNoPubkeysConfigured() = + runBlocking { + // Spin up a *separate* server with no admin pubkeys. + val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() + val openRelay = Relay(url = placeholder) + val openServer = + LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start() + try { + val openHttpUrl = openServer.url.replace("ws://", "http://") + val body = + JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray() + val authToken = + admin + .sign( + HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body), + ).toAuthToken() + httpClient + .newCall( + Request + .Builder() + .url(openHttpUrl) + .post(body.toRequestBody("application/nostr+json+rpc".toMediaType())) + .header("Authorization", authToken) + .build(), + ).execute() + .use { + assertEquals(403, it.code) + } + } finally { + openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500) + openRelay.close() + } + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt new file mode 100644 index 000000000..284470dd2 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt @@ -0,0 +1,198 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.relay.RelayInfo +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip86ServerTest { + private fun fixture(): Triple { + val store = BanStore() + val holder = + Holder(RelayInfo(Nip11RelayInformation(name = "before", description = "before-desc"))) + val server = Nip86Server(banStore = store, infoHolder = holder, store = null) + return Triple(server, store, holder) + } + + private class Holder( + var current: RelayInfo, + ) : Nip86Server.InfoHolder { + override fun get() = current + + override fun set(info: RelayInfo) { + current = info + } + } + + private val pk = "a".repeat(64) + private val pk2 = "b".repeat(64) + private val eventId = "c".repeat(64) + private val relayUrl = RelayUrlNormalizer.normalize("ws://test/") + + @Test + fun supportedMethodsRoundTrip() = + runBlocking { + val (server, _, _) = fixture() + val resp = server.dispatch(Nip86Request.supportedMethods()) + assertNull(resp.error) + val arr = resp.result as JsonArray + val names = arr.map { it.jsonPrimitive.content } + assertTrue(Nip86Method.SUPPORTED_METHODS in names) + assertTrue(Nip86Method.BAN_PUBKEY in names) + assertTrue(Nip86Method.CHANGE_RELAY_NAME in names) + } + + @Test + fun banPubkeyMutatesStoreAndListsRoundTripWithReason() = + runBlocking { + val (server, banStore, _) = fixture() + + val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam")) + assertEquals(true, (ok.result as JsonPrimitive).boolean) + assertTrue(banStore.isBanned(pk)) + + val list = server.dispatch(Nip86Request.listBannedPubkeys()) + val parsed = + kotlinx.serialization.json.Json + .decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()), + list.result as JsonArray, + ) + assertEquals(1, parsed.size) + assertEquals(pk, parsed[0].pubkey) + assertEquals("spam", parsed[0].reason) + + server.dispatch(Nip86Request.unbanPubkey(pk)) + assertTrue(banStore.listBannedPubkeys().isEmpty()) + } + + @Test + fun allowPubkeyAndListRoundTrip() = + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(Nip86Request.allowPubkey(pk, "trusted")) + server.dispatch(Nip86Request.allowPubkey(pk2)) + assertTrue(banStore.hasAllowList()) + + val resp = server.dispatch(Nip86Request.listAllowedPubkeys()) + val list = + kotlinx.serialization.json.Json + .decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()), + resp.result as JsonArray, + ) + assertEquals(2, list.size) + assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet()) + } + + @Test + fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() = + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(Nip86Request.banEvent(eventId, "off-topic")) + assertTrue(banStore.isBannedEvent(eventId)) + + val resp = server.dispatch(Nip86Request.listBannedEvents()) + val list = + kotlinx.serialization.json.Json + .decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()), + resp.result as JsonArray, + ) + assertEquals(1, list.size) + assertEquals(eventId, list[0].id) + assertEquals("off-topic", list[0].reason) + + // allowevent (which is "unban") removes the entry. + server.dispatch(Nip86Request.allowEvent(eventId)) + assertTrue(banStore.listBannedEvents().isEmpty()) + } + + @Test + fun allowKindAndDisallowKind() = + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(Nip86Request.allowKind(1)) + server.dispatch(Nip86Request.allowKind(7)) + server.dispatch(Nip86Request.disallowKind(4)) + + val list = server.dispatch(Nip86Request.listAllowedKinds()) + val ints = (list.result as JsonArray).map { it.jsonPrimitive.int } + assertEquals(listOf(1, 7), ints) + + assertTrue(banStore.isKindAllowed(1)) + assertTrue(banStore.isKindAllowed(7)) + assertEquals(false, banStore.isKindAllowed(4)) + assertEquals(false, banStore.isKindAllowed(99)) + } + + @Test + fun changeRelayNameDescriptionIconRewriteInfoDoc() = + runBlocking { + val (server, _, holder) = fixture() + assertEquals("before", holder.current.document.name) + + server.dispatch(Nip86Request.changeRelayName("after")) + assertEquals("after", holder.current.document.name) + + server.dispatch(Nip86Request.changeRelayDescription("nice relay")) + assertEquals("nice relay", holder.current.document.description) + + server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png")) + assertEquals("https://x/icon.png", holder.current.document.icon) + } + + @Test + fun unsupportedMethodReturnsError() = + runBlocking { + val (server, _, _) = fixture() + val resp = server.dispatch(Nip86Request(method = "frobnicate")) + assertNotNull(resp.error) + assertTrue(resp.error!!.contains("frobnicate")) + } + + @Test + fun missingParamsAreReportedAsErrors() = + runBlocking { + val (server, _, _) = fixture() + // banpubkey requires at least one positional param. + val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY)) + assertNotNull(resp.error) + assertTrue(resp.error!!.startsWith("invalid params")) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt new file mode 100644 index 000000000..50aabd6ca --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt @@ -0,0 +1,121 @@ +/* + * 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.relay.admin + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class Nip98AuthVerifierTest { + private val verifier = Nip98AuthVerifier(now = { 1_000L }) + + private fun signedToken( + url: String, + method: String, + body: ByteArray? = null, + signer: NostrSignerSync = NostrSignerSync(KeyPair()), + createdAt: Long = 1_000L, + ): Pair { + val template = HTTPAuthorizationEvent.build(url = url, method = method, file = body, createdAt = createdAt) + val signed = signer.sign(template) + return signer.pubKey to signed.toAuthToken() + } + + @Test + fun verifiesAValidPostWithBody() = + runBlocking { + val body = "hello".encodeToByteArray() + val (pubkey, header) = signedToken("http://x/", "POST", body) + val r = verifier.verify(header, "POST", "http://x/", body) + assertIs(r) + assertEquals(pubkey, r.pubkey) + } + + @Test + fun missingHeaderReturnsMissing() { + val r = verifier.verify(null, "POST", "http://x/", null) + assertIs(r) + } + + @Test + fun wrongSchemeIsMalformed() { + val r = verifier.verify("Bearer abc", "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("Nostr")) + } + + @Test + fun urlMismatchIsMalformed() { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "POST", "http://y/", null) + assertIs(r) + assertTrue(r.reason.contains("url mismatch")) + } + + @Test + fun methodMismatchIsMalformed() { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "GET", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("method mismatch")) + } + + @Test + fun payloadHashMismatchIsMalformed() { + val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray()) + val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray()) + assertIs(r) + assertTrue(r.reason.contains("payload hash")) + } + + @Test + fun staleCreatedAtIsMalformed() { + // Verifier's clock is fixed at 1_000; sign a token created 5 + // minutes earlier — outside the 60s tolerance. + val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600) + val r = verifier.verify(header, "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("created_at")) + } + + @Test + fun nonAuthEventKindIsMalformed() { + // Build a kind-1 event by hand and shove it into the header — it + // must be rejected because NIP-98 specifically uses kind 27235. + val signer = NostrSignerSync(KeyPair()) + val template = + com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + .build("not an auth event") + val signed = signer.sign(template) + val token = + "Nostr " + + kotlin.io.encoding.Base64 + .encode(signed.toJson().encodeToByteArray()) + val r = verifier.verify(token, "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("kind")) + } +}