diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 5b90bfcb9..d6a1f1026 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server -import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier import io.ktor.http.HttpHeaders import io.ktor.server.application.install import io.ktor.server.application.serverConfig @@ -108,9 +108,7 @@ class KtorRelay( object : Nip86Server.InfoHolder { override fun get(): Nip11RelayInformation = relay.info.document - override fun set(info: Nip11RelayInformation) { - relay.updateInfo { info } - } + override fun set(info: Nip11RelayInformation) = relay.updateInfo { info } } private val nip86Server = @@ -118,19 +116,12 @@ class KtorRelay( banStore = relay.banStore, infoHolder = infoHolder, onBan = { filter -> relay.store.delete(filter) }, + allowList = adminPubkeys, ) + private val nip86Route = Nip86HttpRoute( - server = nip86Server, - verifier = Nip98AuthVerifier(), - allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() }, - // 1 MiB. Bounded *before* NIP-98 auth verification — we - // have to read the body to compute its sha256 for the - // payload binding, so an unbounded read would let an - // unauthenticated attacker stream gigabytes and OOM the - // relay. NIP-86 RPC payloads are a few hundred bytes; - // 1 MiB is ~1000× any plausible request. - maxBodyBytes = 1 shl 20, + handler = Nip86HttpHandler(server = nip86Server), signedUrlFor = { call -> publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) }, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt index 3e1e3534e..41c520022 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt @@ -20,12 +20,7 @@ */ package com.vitorpamplona.geode.server -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.JsonMapper -import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request -import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response -import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server -import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -36,90 +31,119 @@ import io.ktor.server.response.respondText import io.ktor.utils.io.readAvailable /** - * NIP-86 admin POST handler. Owns the gating order: - * 1. 403 if no admin pubkey list is configured (endpoint disabled). - * 2. 413 if body exceeds [maxBodyBytes] (declared or actual). - * 3. 401 if the NIP-98 Authorization header is missing/invalid. - * 4. 403 if the verified pubkey isn't in [allowList]. - * 5. 400 if the body isn't a valid Nip86Request. - * 6. 200 with a Nip86Response JSON body otherwise. + * Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by + * [Nip86HttpHandler]. This class is intentionally thin: it pulls the + * Authorization header, signed URL, and body bytes out of the + * [ApplicationCall], hands them to the handler, then maps each + * [Nip86HttpHandler.Response] variant to the Ktor status / header / + * body it expects. * - * The [signedUrlFor] callback resolves what URL the client must have - * signed in their NIP-98 token. Operators configure the canonical - * `publicUrl`; loopback tests fall back to the request's `Host` - * header. We pass it as a callback rather than a string so the route - * doesn't need to know about Ktor request internals. + * The bounded body read happens here (Ktor exposes `ByteReadChannel`, + * which is framework-specific) — we stop reading as soon as we'd + * exceed [Nip86HttpHandler.maxBodyBytes] so an unauthenticated + * attacker can't OOM the relay with a giant stream. + * + * Audit logging also lives here, off [Nip86HttpHandler.Response.Ok] — + * the handler keeps logging policy out of quartz; the geode adapter + * picks a stderr line format and runs with it. */ internal class Nip86HttpRoute( - private val server: Nip86Server, - private val verifier: Nip98AuthVerifier, - private val allowList: Set, - private val maxBodyBytes: Int, + private val handler: Nip86HttpHandler, private val signedUrlFor: (ApplicationCall) -> String, ) { suspend fun handle(call: ApplicationCall) { - if (allowList.isEmpty()) { - call.respondText( - "NIP-86 management API is not enabled on this relay.", - ContentType.Text.Plain, - HttpStatusCode.Forbidden, - ) - return - } + val body = readBoundedBody(call) ?: return // 413 already sent + val authHeader = call.request.header(HttpHeaders.Authorization) + val url = signedUrlFor(call) - val body = readBoundedBody(call) ?: return - val pubkey = verifyAuth(call, body) ?: return - if (pubkey.lowercase() !in allowList) { - 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) { + when (val r = handler.handle(authHeader, url, body)) { + Nip86HttpHandler.Response.Disabled -> { call.respondText( - "invalid Nip86Request: ${e.message ?: e::class.simpleName}", + "NIP-86 management API is not enabled on this relay.", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + } + + is Nip86HttpHandler.Response.PayloadTooLarge -> { + call.respondText( + "request body exceeds ${r.cap}-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) + } + + Nip86HttpHandler.Response.MissingAuth -> { + call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip86HttpHandler.WWW_AUTHENTICATE) + call.respondText( + "missing Authorization header (NIP-98)", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + } + + is Nip86HttpHandler.Response.BadAuth -> { + call.respondText( + "invalid NIP-98 Authorization: ${r.reason}", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + } + + Nip86HttpHandler.Response.NotAdmin -> { + call.respondText( + "pubkey is not on the admin list", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + } + + is Nip86HttpHandler.Response.BadRequest -> { + call.respondText( + r.reason, ContentType.Text.Plain, HttpStatusCode.BadRequest, ) - return } - val response: Nip86Response = server.dispatch(req) - audit(pubkey, req, response) - call.respondText( - JsonMapper.toJson(response), - ContentType.parse("application/nostr+json+rpc"), - HttpStatusCode.OK, - ) + is Nip86HttpHandler.Response.Ok -> { + audit(r) + call.respondText( + r.json, + ContentType.parse(Nip86HttpHandler.CONTENT_TYPE), + HttpStatusCode.OK, + ) + } + } } + /** + * Bounded read using `handler.maxBodyBytes`. Returns null after + * sending a 413 if the request body exceeds the cap — either the + * declared `Content-Length` or what we actually pull off the wire. + */ private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? { + val cap = handler.maxBodyBytes val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() - if (declared != null && declared > maxBodyBytes) { + if (declared != null && declared > cap) { call.respondText( - "request body exceeds $maxBodyBytes-byte cap", + "request body exceeds $cap-byte cap", ContentType.Text.Plain, HttpStatusCode.PayloadTooLarge, ) return null } val ch = call.receiveChannel() - val buf = ByteArray(maxBodyBytes + 1) + val buf = ByteArray(cap + 1) var pos = 0 - while (pos <= maxBodyBytes) { + while (pos <= cap) { val read = ch.readAvailable(buf, pos, buf.size - pos) if (read <= 0) break pos += read } - if (pos > maxBodyBytes) { + if (pos > cap) { call.respondText( - "request body exceeds $maxBodyBytes-byte cap", + "request body exceeds $cap-byte cap", ContentType.Text.Plain, HttpStatusCode.PayloadTooLarge, ) @@ -128,53 +152,16 @@ internal class Nip86HttpRoute( return buf.copyOfRange(0, pos) } - private suspend fun verifyAuth( - call: ApplicationCall, - body: ByteArray, - ): HexKey? { - val header = call.request.header(HttpHeaders.Authorization) - val verification = verifier.verify(header, method = "POST", url = signedUrlFor(call), body = body) - return 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, - ) - null - } - - is Nip98AuthVerifier.Result.Malformed -> { - call.respondText( - "invalid NIP-98 Authorization: ${verification.reason}", - ContentType.Text.Plain, - HttpStatusCode.Unauthorized, - ) - null - } - } - } - /** - * Audit log: structured single line so an operator can grep - * "nip86" / pubkey / method without a logging framework - * dependency. Best-effort — a missing log line shouldn't fail - * the response. + * Single-line stderr audit. Operators grep on "nip86" / pubkey / + * method without needing a logging framework. Best-effort — a + * failed log line must not fail the response. */ - private fun audit( - pubkey: HexKey, - req: Nip86Request, - response: Nip86Response, - ) { + private fun audit(ok: Nip86HttpHandler.Response.Ok) { runCatching { System.err.println( - "nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" + - (response.error?.let { " error=$it" } ?: ""), + "nip86 audit pubkey=${ok.pubkey} method=${ok.request.method} ok=${ok.response.error == null}" + + (ok.response.error?.let { " error=$it" } ?: ""), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt new file mode 100644 index 000000000..9306c4e61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandler.kt @@ -0,0 +1,155 @@ +/* + * 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.nip86RelayManagement.server + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier + +/** + * Canonical NIP-86-over-HTTP entry point. Drives the complete flow + * the spec mandates — POST `application/nostr+json+rpc` with a + * NIP-98 `Authorization: Nostr ` header — so any relay + * implementation can plug its HTTP framework in without re-deriving + * the auth / parse / dispatch / serialize sequence: + * + * 1. **Gate.** If `server.isEnabled()` is false (empty admin list), + * reject with [Response.Disabled] (→ 403, "admin API not + * enabled"). No body read, no signature verify. + * 2. **Size cap.** If `body.size > maxBodyBytes`, reject with + * [Response.PayloadTooLarge]. Adapters MUST also bound the read + * itself — NIP-98 forces us to compute sha256 over the full + * body for signature binding, so an unbounded read is a pre-auth + * OOM vector. This check is defense-in-depth. + * 3. **Verify.** Run [Nip98AuthVerifier.verify] (method = `POST`, + * given [url] and [body]). Missing → [Response.MissingAuth] + * (→ 401 + `WWW-Authenticate: Nostr`). Malformed → + * [Response.BadAuth] (→ 401). + * 4. **Admin check.** If the verified pubkey is not in + * `server.isAuthorized`, reject with [Response.NotAdmin] (→ 403). + * 5. **Parse.** Decode [Nip86Request] from the body bytes. Invalid + * → [Response.BadRequest] (→ 400). + * 6. **Dispatch.** Call `server.dispatch(pubkey, req)` and wrap the + * [Nip86Response] in [Response.Ok], pre-serialized as JSON ready + * to write to the wire with `Content-Type: application/nostr+json+rpc`. + * + * Transport-agnostic: takes raw primitives ([authHeader], [url], + * [body]) and returns a sealed [Response]. The adapter maps each + * variant to its framework's status-code / header API — for Ktor, + * see `geode/server/Nip86HttpRoute`. + * + * @param maxBodyBytes Defense-in-depth cap. NIP-86 RPC payloads are + * a few hundred bytes; the 1 MiB default is ~1000× any plausible + * request, but small enough that an attacker can't OOM the relay + * if the adapter forgets its own bound. + */ +class Nip86HttpHandler( + private val server: Nip86Server, + private val verifier: Nip98AuthVerifier = Nip98AuthVerifier(), + val maxBodyBytes: Int = DEFAULT_MAX_BODY_BYTES, +) { + suspend fun handle( + authHeader: String?, + url: String, + body: ByteArray, + ): Response { + if (!server.isEnabled()) return Response.Disabled + if (body.size > maxBodyBytes) return Response.PayloadTooLarge(maxBodyBytes) + + val pubkey = + when (val v = verifier.verify(authHeader, method = "POST", url = url, body = body)) { + is Nip98AuthVerifier.Result.Verified -> v.pubkey + Nip98AuthVerifier.Result.Missing -> return Response.MissingAuth + is Nip98AuthVerifier.Result.Malformed -> return Response.BadAuth(v.reason) + } + + if (!server.isAuthorized(pubkey)) return Response.NotAdmin + + val req = + try { + JsonMapper.fromJson(body.decodeToString()) + } catch (e: Exception) { + return Response.BadRequest("invalid Nip86Request: ${e.message ?: e::class.simpleName}") + } + + val response = server.dispatch(pubkey, req) + return Response.Ok(pubkey, req, response, JsonMapper.toJson(response)) + } + + /** + * Outcome of one canonical NIP-86 HTTP request. Adapters map each + * variant to a status code + body: + * + * | Variant | HTTP | Notes | + * |---|---|---| + * | [Disabled] | 403 | "admin API not enabled" | + * | [PayloadTooLarge] | 413 | adapter SHOULD bound the read itself | + * | [MissingAuth] | 401 | send `WWW-Authenticate: Nostr` | + * | [BadAuth] | 401 | NIP-98 signature/binding failed | + * | [NotAdmin] | 403 | verified pubkey not on allow-list | + * | [BadRequest] | 400 | body wasn't a valid `Nip86Request` | + * | [Ok] | 200 | `Content-Type: application/nostr+json+rpc`; write [Ok.json] | + */ + sealed interface Response { + data object Disabled : Response + + data class PayloadTooLarge( + val cap: Int, + ) : Response + + data object MissingAuth : Response + + data class BadAuth( + val reason: String, + ) : Response + + data object NotAdmin : Response + + data class BadRequest( + val reason: String, + ) : Response + + /** + * Successful round-trip. Carries the verified [pubkey] and + * decoded [request] alongside the serialized response, so + * adapters can audit / log without re-parsing. + */ + data class Ok( + val pubkey: HexKey, + val request: Nip86Request, + val response: Nip86Response, + val json: String, + ) : Response + } + + companion object { + /** 1 MiB — see class KDoc on the cap rationale. */ + const val DEFAULT_MAX_BODY_BYTES: Int = 1 shl 20 + + /** `application/nostr+json+rpc` — the wire type for both directions. */ + const val CONTENT_TYPE: String = "application/nostr+json+rpc" + + /** Value for the `WWW-Authenticate` header on 401 responses. Matches NIP-98. */ + const val WWW_AUTHENTICATE: String = "Nostr" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 134058130..a8e6afd15 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip86RelayManagement.server +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey @@ -45,12 +46,18 @@ import kotlinx.serialization.json.int * * Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder] * for the live NIP-11 doc (mutated by `changerelay*` methods, which - * atomically swap it), and an [onBan] hook so each ban method can - * retroactively purge matching events from the relay's event store. + * atomically swap it), an [onBan] hook so each ban method can + * retroactively purge matching events from the relay's event store, + * and the [allowList] of admin pubkeys authorized to call admin RPCs. * * Transport-agnostic — relay implementations call [dispatch] from * whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`), - * and in-process tests can build a [Nip86Request] directly. + * and in-process tests can build a [Nip86Request] directly. The + * [allowList] check is enforced inside [dispatch] so no caller can + * accidentally bypass it; transports also have [isEnabled] and + * [isAuthorized] to distinguish "endpoint disabled" (empty list, e.g. + * 403 + "not enabled") from "not allowed" (valid token but + * unrecognized pubkey, e.g. 403 + "not on admin list"). * * [supportedMethods] is the canonical list this server actually * implements; methods returned outside of it are no-ops and a NIP-86 @@ -83,7 +90,21 @@ class Nip86Server( * `geode/KtorRelay`) wire it to `store.delete(filter)`. */ private val onBan: suspend (Filter) -> Unit = {}, + /** + * Pubkeys allowed to invoke admin RPCs. Empty disables the admin + * API entirely — [isEnabled] returns false and [dispatch] rejects + * everything. Compared case-insensitively (lowercased on entry). + */ + allowList: Set = emptySet(), ) { + private val allowList: Set = allowList.mapTo(HashSet()) { it.lowercase() } + + /** True when at least one admin pubkey is configured. */ + fun isEnabled(): Boolean = allowList.isNotEmpty() + + /** True when [pubkey] is on the admin allow-list. Case-insensitive. */ + fun isAuthorized(pubkey: HexKey): Boolean = pubkey.lowercase() in allowList + /** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */ interface InfoHolder { fun get(): Nip11RelayInformation @@ -112,11 +133,24 @@ class Nip86Server( ) /** - * Dispatches a single RPC request. Synchronous-looking but does - * suspend internally for the `banevent` event-store delete path. + * Dispatches a single RPC request from [pubkey] (the caller, as + * authenticated by the transport — NIP-98 over HTTP, NIP-42 over + * WS, or in-process trust). + * + * If [pubkey] is not in [allowList], returns a `not authorized` + * error response without executing anything. Transports that + * surface different HTTP statuses for "disabled" vs "not on list" + * should pre-check via [isEnabled] / [isAuthorized] instead of + * relying on this string. */ - suspend fun dispatch(req: Nip86Request): Nip86Response = - runCatching { + suspend fun dispatch( + pubkey: HexKey, + req: Nip86Request, + ): Nip86Response { + if (!isAuthorized(pubkey)) { + return Nip86Response(error = "pubkey is not on the admin list") + } + return runCatching { when (req.method) { Nip86Method.SUPPORTED_METHODS -> { ok(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } }) @@ -207,6 +241,7 @@ class Nip86Server( if (e is CancellationException) throw e Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}") } + } private inline fun withHex( req: Nip86Request, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt new file mode 100644 index 000000000..5ef6ede04 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86HttpHandlerTest.kt @@ -0,0 +1,155 @@ +/* + * 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.nip86RelayManagement.server + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip86HttpHandlerTest { + private val now = 1_000L + private val verifier = Nip98AuthVerifier(now = { now }) + private val adminSigner = NostrSignerSync(KeyPair()) + private val intruderSigner = NostrSignerSync(KeyPair()) + private val url = "http://relay.example.com/" + + private fun handlerWith(adminInList: Boolean = true): Pair { + val server = + Nip86Server( + banStore = BanStore(), + infoHolder = + object : Nip86Server.InfoHolder { + private var doc = Nip11RelayInformation(name = "fixture") + + override fun get() = doc + + override fun set(info: Nip11RelayInformation) { + doc = info + } + }, + allowList = if (adminInList) setOf(adminSigner.pubKey) else emptySet(), + ) + return Nip86HttpHandler(server, verifier) to server + } + + private fun signedHeader( + body: ByteArray, + signer: NostrSignerSync = adminSigner, + ): String { + val template = HTTPAuthorizationEvent.build(url = url, method = "POST", file = body, createdAt = now) + return signer.sign(template).toAuthToken() + } + + private val supportedMethodsBody: ByteArray + get() = JsonMapper.toJson(Nip86Request(method = Nip86Method.SUPPORTED_METHODS)).encodeToByteArray() + + @Test + fun disabledWhenAllowListIsEmpty() { + runBlocking { + val (handler, _) = handlerWith(adminInList = false) + val body = supportedMethodsBody + val r = handler.handle(signedHeader(body), url, body) + assertIs(r) + } + } + + @Test + fun payloadTooLargeBeforeAuthCheck() { + runBlocking { + val (handler, _) = handlerWith() + val oversized = ByteArray(handler.maxBodyBytes + 1) + // No need for a valid signature — size check fires first. + val r = handler.handle("anything", url, oversized) + assertIs(r) + assertEquals(handler.maxBodyBytes, r.cap) + } + } + + @Test + fun missingAuthHeader() { + runBlocking { + val (handler, _) = handlerWith() + val r = handler.handle(authHeader = null, url = url, body = supportedMethodsBody) + assertIs(r) + } + } + + @Test + fun malformedAuthIsBadAuth() { + runBlocking { + val (handler, _) = handlerWith() + val r = handler.handle("Bearer not-a-nostr-token", url, supportedMethodsBody) + assertIs(r) + assertTrue(r.reason.contains("Nostr")) + } + } + + @Test + fun verifiedButNotAdminIsRejectedAsNotAdmin() { + runBlocking { + val (handler, _) = handlerWith() // admin is adminSigner only + val body = supportedMethodsBody + val header = signedHeader(body, signer = intruderSigner) + val r = handler.handle(header, url, body) + assertIs(r) + } + } + + @Test + fun verifiedAdminButBadJsonBodyIsBadRequest() { + runBlocking { + val (handler, _) = handlerWith() + val body = "not valid json {".encodeToByteArray() + val header = signedHeader(body) + val r = handler.handle(header, url, body) + assertIs(r) + } + } + + @Test + fun verifiedAdminWithValidRequestDispatches() { + runBlocking { + val (handler, _) = handlerWith() + val body = supportedMethodsBody + val header = signedHeader(body) + val r = handler.handle(header, url, body) + val ok = assertIs(r) + assertEquals(adminSigner.pubKey, ok.pubkey) + assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method) + assertNull(ok.response.error) + assertNotNull(ok.response.result) + // Pre-serialized JSON ready to write to the wire. + assertTrue(ok.json.contains(Nip86Method.SUPPORTED_METHODS)) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index da5b84203..e054122ed 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -42,10 +42,13 @@ class Nip86ServerTest { private fun fixture(): Triple { val store = BanStore() val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc")) - val server = Nip86Server(banStore = store, infoHolder = holder) + val server = Nip86Server(banStore = store, infoHolder = holder, allowList = setOf(admin)) return Triple(server, store, holder) } + /** Admin pubkey used in all dispatch calls — must match the allow-list above. */ + private val admin = "d".repeat(64) + private class Holder( var current: Nip11RelayInformation, ) : Nip86Server.InfoHolder { @@ -64,7 +67,7 @@ class Nip86ServerTest { fun supportedMethodsRoundTrip() { runBlocking { val (server, _, _) = fixture() - val resp = server.dispatch(Nip86Request.supportedMethods()) + val resp = server.dispatch(admin, Nip86Request.supportedMethods()) assertNull(resp.error) val arr = resp.result as JsonArray val names = arr.map { it.jsonPrimitive.content } @@ -79,11 +82,11 @@ class Nip86ServerTest { runBlocking { val (server, banStore, _) = fixture() - val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam")) + val ok = server.dispatch(admin, Nip86Request.banPubkey(pk, "spam")) assertEquals(true, (ok.result as JsonPrimitive).boolean) assertTrue(banStore.isBanned(pk)) - val list = server.dispatch(Nip86Request.listBannedPubkeys()) + val list = server.dispatch(admin, Nip86Request.listBannedPubkeys()) val parsed = kotlinx.serialization.json.Json .decodeFromJsonElement( @@ -94,7 +97,7 @@ class Nip86ServerTest { assertEquals(pk, parsed[0].pubkey) assertEquals("spam", parsed[0].reason) - server.dispatch(Nip86Request.unbanPubkey(pk)) + server.dispatch(admin, Nip86Request.unbanPubkey(pk)) assertTrue(banStore.listBannedPubkeys().isEmpty()) } } @@ -103,11 +106,11 @@ class Nip86ServerTest { fun allowPubkeyAndListRoundTrip() { runBlocking { val (server, banStore, _) = fixture() - server.dispatch(Nip86Request.allowPubkey(pk, "trusted")) - server.dispatch(Nip86Request.allowPubkey(pk2)) + server.dispatch(admin, Nip86Request.allowPubkey(pk, "trusted")) + server.dispatch(admin, Nip86Request.allowPubkey(pk2)) assertTrue(banStore.hasAllowList()) - val resp = server.dispatch(Nip86Request.listAllowedPubkeys()) + val resp = server.dispatch(admin, Nip86Request.listAllowedPubkeys()) val list = kotlinx.serialization.json.Json .decodeFromJsonElement( @@ -123,10 +126,10 @@ class Nip86ServerTest { fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() { runBlocking { val (server, banStore, _) = fixture() - server.dispatch(Nip86Request.banEvent(eventId, "off-topic")) + server.dispatch(admin, Nip86Request.banEvent(eventId, "off-topic")) assertTrue(banStore.isBannedEvent(eventId)) - val resp = server.dispatch(Nip86Request.listBannedEvents()) + val resp = server.dispatch(admin, Nip86Request.listBannedEvents()) val list = kotlinx.serialization.json.Json .decodeFromJsonElement( @@ -138,7 +141,7 @@ class Nip86ServerTest { assertEquals("off-topic", list[0].reason) // allowevent (which is "unban") removes the entry. - server.dispatch(Nip86Request.allowEvent(eventId)) + server.dispatch(admin, Nip86Request.allowEvent(eventId)) assertTrue(banStore.listBannedEvents().isEmpty()) } } @@ -147,11 +150,11 @@ class Nip86ServerTest { fun allowKindAndDisallowKind() { runBlocking { val (server, banStore, _) = fixture() - server.dispatch(Nip86Request.allowKind(1)) - server.dispatch(Nip86Request.allowKind(7)) - server.dispatch(Nip86Request.disallowKind(4)) + server.dispatch(admin, Nip86Request.allowKind(1)) + server.dispatch(admin, Nip86Request.allowKind(7)) + server.dispatch(admin, Nip86Request.disallowKind(4)) - val list = server.dispatch(Nip86Request.listAllowedKinds()) + val list = server.dispatch(admin, Nip86Request.listAllowedKinds()) val ints = (list.result as JsonArray).map { it.jsonPrimitive.int } assertEquals(listOf(1, 7), ints) @@ -168,13 +171,13 @@ class Nip86ServerTest { val (server, _, holder) = fixture() assertEquals("before", holder.current.name) - server.dispatch(Nip86Request.changeRelayName("after")) + server.dispatch(admin, Nip86Request.changeRelayName("after")) assertEquals("after", holder.current.name) - server.dispatch(Nip86Request.changeRelayDescription("nice relay")) + server.dispatch(admin, Nip86Request.changeRelayDescription("nice relay")) assertEquals("nice relay", holder.current.description) - server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png")) + server.dispatch(admin, Nip86Request.changeRelayIcon("https://x/icon.png")) assertEquals("https://x/icon.png", holder.current.icon) } } @@ -183,7 +186,7 @@ class Nip86ServerTest { fun unsupportedMethodReturnsError() { runBlocking { val (server, _, _) = fixture() - val resp = server.dispatch(Nip86Request(method = "frobnicate")) + val resp = server.dispatch(admin, Nip86Request(method = "frobnicate")) assertNotNull(resp.error) assertTrue(resp.error!!.contains("frobnicate")) } @@ -194,9 +197,41 @@ class Nip86ServerTest { runBlocking { val (server, _, _) = fixture() // banpubkey requires at least one positional param. - val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY)) + val resp = server.dispatch(admin, Nip86Request(method = Nip86Method.BAN_PUBKEY)) assertNotNull(resp.error) assertTrue(resp.error!!.startsWith("invalid params")) } } + + @Test + fun dispatchRejectsPubkeyNotOnAllowList() { + runBlocking { + val (server, banStore, _) = fixture() + val intruder = "e".repeat(64) + val resp = server.dispatch(intruder, Nip86Request.banPubkey(pk, "spam")) + assertNotNull(resp.error) + assertTrue(resp.error!!.contains("not on the admin list")) + // And no state was mutated. + assertTrue(!banStore.isBanned(pk)) + } + } + + @Test + fun emptyAllowListDisablesTheServer() { + val store = BanStore() + val holder = Holder(Nip11RelayInformation(name = "x")) + val server = Nip86Server(banStore = store, infoHolder = holder) // no allowList + assertEquals(false, server.isEnabled()) + assertEquals(false, server.isAuthorized(admin)) + } + + @Test + fun allowListIsCaseInsensitive() { + val store = BanStore() + val holder = Holder(Nip11RelayInformation(name = "x")) + // Configure in upper-case; lookups normalize. + val server = Nip86Server(banStore = store, infoHolder = holder, allowList = setOf(admin.uppercase())) + assertTrue(server.isAuthorized(admin)) + assertTrue(server.isAuthorized(admin.uppercase())) + } }