refactor(quartz, geode): derive NIP-86 publicUrl from relay.url; drop isEnabled branch

Two simplifications:

1. Derive admin URL from RelayEngine.url. NIP-86 spec mandates that
   the admin endpoint is "the same URI as ws(s)://, called via
   http(s)://" — so KtorRelay derives the NIP-98 binding URL by
   calling relay.url.toHttp() instead of accepting publicUrl as a
   separate config. Single source of truth (info.relay_url),
   accidental misconfiguration impossible, no Host-header fallback.
   StaticConfig.AdminSection.public_url is gone.

2. Uniform code path for admin-enabled vs disabled. Empty allow-list
   isn't a special case anywhere: Nip86Server.isAuthorized returns
   false for everyone, dispatch rejects, Nip86HttpHandler returns
   NotAdmin → 403. KtorRelay always assembles the Nip86HttpRoute
   and registers the POST endpoint; Nip86Server.isEnabled() and the
   "is admin on?" branches in the handler and route are deleted.

Nip86EndToEndTest's admin-disabled case is now a behavioral test:
sign a valid token, expect 403 NotAdmin (was 405 with the no-route
variant, was 403 with the fake-disabled-route variant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 18:19:24 -04:00
parent a53b3339b3
commit 445fbf8cda
10 changed files with 192 additions and 129 deletions
+6 -7
View File
@@ -51,7 +51,7 @@ file = "/var/lib/geode/events.db"
# recommended for any relay accepting traffic from real clients. # recommended for any relay accepting traffic from real clients.
# Verify Schnorr signatures on every EVENT. Default: true. Disable # Verify Schnorr signatures on every EVENT. Default: true. Disable
# only for trusted-input scenarios (test fixtures, mirror replays). # only for trusted-input scenarios (test fixtures, mirror replays).
# verify_signatures = true verify_signatures = true
# Run signature verification in parallel inside the IngestQueue # Run signature verification in parallel inside the IngestQueue
# (across all CPU cores) instead of serially on each connection's # (across all CPU cores) instead of serially on each connection's
@@ -80,13 +80,12 @@ require_auth = false
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of # authenticated with NIP-98 HTTP-Auth. Only events signed by one of
# the listed pubkeys can run admin RPCs (banpubkey / banevent / # the listed pubkeys can run admin RPCs (banpubkey / banevent /
# changerelayname / …). Empty (the default) disables the endpoint. # changerelayname / …). Empty (the default) disables the endpoint.
# pubkeys = ["abcdef...64hex..."]
# #
# Canonical URL the relay is reachable at, e.g. behind a reverse proxy. # NIP-98 binds admin tokens to the relay's HTTP URL, which is derived
# NIP-98 binds requests to this URL via the `u` tag. **Required** in # from [info].relay_url with the scheme swapped (ws -> http, wss ->
# any production deployment — without it, an attacker can spoof the # https) per NIP-86. Make sure [info].relay_url is set to the
# Host header to bypass URL binding. # canonical public URL when behind TLS termination or a reverse proxy.
# public_url = "https://relay.example.com/" # pubkeys = ["abcdef...64hex..."]
# Path for the JSON snapshot that persists NIP-86 admin state (ban # Path for the JSON snapshot that persists NIP-86 admin state (ban
# lists + the live NIP-11 doc) across restarts. When unset, admin # lists + the live NIP-11 doc) across restarts. When unset, admin
@@ -25,18 +25,17 @@ import com.vitorpamplona.geode.server.Nip86HttpRoute
import com.vitorpamplona.geode.server.WebSocketSessionPump import com.vitorpamplona.geode.server.WebSocketSessionPump
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import io.ktor.http.HttpHeaders
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.application.serverConfig import io.ktor.server.application.serverConfig
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.connector import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.header
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
@@ -79,20 +78,6 @@ class KtorRelay(
* gated by NIP-98 HTTP-Auth membership in this set. * gated by NIP-98 HTTP-Auth membership in this set.
*/ */
val adminPubkeys: Set<HexKey> = emptySet(), val adminPubkeys: Set<HexKey> = emptySet(),
/**
* Canonical public URL the relay is reachable at, e.g.
* `https://relay.example.com/`. NIP-98 admin requests must sign
* the **same** URL string they're sending to. When the relay sits
* behind TLS termination or a reverse proxy, the `Host` header
* the relay sees does not match what the client signs, so the
* verifier must compare against this configured value.
*
* `null` (the default) falls back to the request's `Host` header
* with `http://` — fine for local-loopback unit tests, **NOT
* SAFE** in a public deployment because an attacker can spoof
* `Host` and bind their signature to any URL.
*/
val publicUrl: String? = null,
/** /**
* Ktor CIO acceptor-thread count. `null` keeps Ktor's default. * Ktor CIO acceptor-thread count. `null` keeps Ktor's default.
* Lift on machines with many cores when targeting 10k+ * Lift on machines with many cores when targeting 10k+
@@ -119,13 +104,25 @@ class KtorRelay(
allowList = adminPubkeys, allowList = adminPubkeys,
) )
/**
* Always assembled — when [adminPubkeys] is empty, the route
* still runs the canonical NIP-86 flow but every request fails
* the allow-list check ([Nip86HttpHandler.Response.NotAdmin] →
* 403). Uniform code path: the transport doesn't branch on
* "admin enabled?".
*
* The NIP-98 binding URL is derived from `relay.url` via [toHttp]
* because NIP-86 mandates it: admin requests target the same URI
* as the WebSocket endpoint, just with `http(s)://` instead of
* `ws(s)://`. This makes accidental misconfiguration impossible —
* the operator's configured `[info].relay_url` is the single
* source of truth.
*/
private val nip86Route = private val nip86Route =
Nip86HttpRoute( Nip86HttpRoute(
handler = Nip86HttpHandler(server = nip86Server), handler = Nip86HttpHandler(server = nip86Server, publicUrl = relay.url.toHttp()),
signedUrlFor = { call ->
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
},
) )
private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json }) private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json })
private var engine: CIOApplicationEngine? = null private var engine: CIOApplicationEngine? = null
@@ -190,6 +187,8 @@ class KtorRelay(
} }
// NIP-86: POST application/nostr+json+rpc with a NIP-98 // NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch. // signed Authorization header → JSON-RPC dispatch.
// Always mounted; an empty [adminPubkeys] just means
// every request fails the allow-list check (403).
post(path) { post(path) {
nip86Route.handle(call) nip86Route.handle(call)
} }
@@ -151,7 +151,6 @@ fun main(args: Array<String>) {
port = port, port = port,
path = path, path = path,
adminPubkeys = config.admin.pubkeys.toSet(), adminPubkeys = config.admin.pubkeys.toSet(),
publicUrl = config.admin.public_url,
connectionGroupSize = config.network.connection_group_size, connectionGroupSize = config.network.connection_group_size,
workerGroupSize = config.network.worker_group_size, workerGroupSize = config.network.worker_group_size,
callGroupSize = config.network.call_group_size, callGroupSize = config.network.call_group_size,
@@ -202,15 +202,15 @@ data class StaticConfig(
* HTTP-Auth. Only requests signed by one of these pubkeys are * HTTP-Auth. Only requests signed by one of these pubkeys are
* dispatched. * dispatched.
* *
* [public_url] is the canonical URL the relay is reachable at, * The NIP-98 URL binding compares the signed `u` tag against the
* e.g. `https://relay.example.com/`. NIP-98's URL binding compares * `http(s)://` equivalent of the relay's WebSocket URL — i.e.
* the signed `u` tag against this — without it, an attacker can * `[info].relay_url` with the scheme swapped (per NIP-86's
* spoof the `Host` header to bind their signature to any URL. * "same URI as `ws(s)://`, called via `http(s)://`"). Set
* Required when running behind TLS termination or a reverse proxy. * `[info].relay_url` to the canonical public URL when running
* behind TLS termination or a reverse proxy.
*/ */
data class AdminSection( data class AdminSection(
val pubkeys: List<String> = emptyList(), val pubkeys: List<String> = emptyList(),
val public_url: String? = null,
/** /**
* Path for the JSON snapshot that backs [RuntimeConfig] — * Path for the JSON snapshot that backs [RuntimeConfig] —
* NIP-86 admin state (ban lists + the live NIP-11 doc) that * NIP-86 admin state (ban lists + the live NIP-11 doc) that
@@ -32,39 +32,33 @@ import io.ktor.utils.io.readAvailable
/** /**
* Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by * Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by
* [Nip86HttpHandler]. This class is intentionally thin: it pulls the * [Nip86HttpHandler]. Intentionally thin: pull the Authorization
* Authorization header, signed URL, and body bytes out of the * header and body bytes out of the [ApplicationCall], hand them to
* [ApplicationCall], hands them to the handler, then maps each * the handler, map each [Nip86HttpHandler.Response] variant to a
* [Nip86HttpHandler.Response] variant to the Ktor status / header / * Ktor status/header/body.
* body it expects.
* *
* The bounded body read happens here (Ktor exposes `ByteReadChannel`, * Always wired — when the relay's admin allow-list is empty, every
* request just fails the [Nip86HttpHandler.Response.NotAdmin] check
* and returns 403. No "is admin on?" branch in either the handler
* or the route.
*
* Bounded body read happens here (Ktor exposes `ByteReadChannel`,
* which is framework-specific) — we stop reading as soon as we'd * which is framework-specific) — we stop reading as soon as we'd
* exceed [Nip86HttpHandler.maxBodyBytes] so an unauthenticated * exceed [Nip86HttpHandler.maxBodyBytes] so an unauthenticated
* attacker can't OOM the relay with a giant stream. * attacker can't OOM the relay with a giant stream.
* *
* Audit logging also lives here, off [Nip86HttpHandler.Response.Ok] — * Audit logging stays here, off [Nip86HttpHandler.Response.Ok] — the
* the handler keeps logging policy out of quartz; the geode adapter * handler keeps logging policy out of quartz; the geode adapter picks
* picks a stderr line format and runs with it. * a stderr line format and runs with it.
*/ */
internal class Nip86HttpRoute( internal class Nip86HttpRoute(
private val handler: Nip86HttpHandler, private val handler: Nip86HttpHandler,
private val signedUrlFor: (ApplicationCall) -> String,
) { ) {
suspend fun handle(call: ApplicationCall) { suspend fun handle(call: ApplicationCall) {
val body = readBoundedBody(call) ?: return // 413 already sent val body = readBoundedBody(call, handler.maxBodyBytes) ?: return // 413 already sent
val authHeader = call.request.header(HttpHeaders.Authorization) val authHeader = call.request.header(HttpHeaders.Authorization)
val url = signedUrlFor(call)
when (val r = handler.handle(authHeader, url, body)) {
Nip86HttpHandler.Response.Disabled -> {
call.respondText(
"NIP-86 management API is not enabled on this relay.",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
}
when (val r = handler.handle(authHeader, body)) {
is Nip86HttpHandler.Response.PayloadTooLarge -> { is Nip86HttpHandler.Response.PayloadTooLarge -> {
call.respondText( call.respondText(
"request body exceeds ${r.cap}-byte cap", "request body exceeds ${r.cap}-byte cap",
@@ -118,12 +112,14 @@ internal class Nip86HttpRoute(
} }
/** /**
* Bounded read using `handler.maxBodyBytes`. Returns null after * Bounded read using [cap]. Returns null after sending a 413 if
* sending a 413 if the request body exceeds the cap — either the * the request body exceeds the cap — either the declared
* declared `Content-Length` or what we actually pull off the wire. * `Content-Length` or what we actually pull off the wire.
*/ */
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? { private suspend fun readBoundedBody(
val cap = handler.maxBodyBytes call: ApplicationCall,
cap: Int,
): ByteArray? {
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > cap) { if (declared != null && declared > cap) {
call.respondText( call.respondText(
@@ -72,13 +72,19 @@ class Nip86EndToEndTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() // KtorRelay derives the NIP-98 admin URL from relay.url (per
relay = RelayEngine(url = placeholder) // NIP-86: same URI as the WebSocket, just http(s)://). We must
// therefore pre-allocate the port and set relay.url to match
// — placeholder + OS-assigned port would cause the URL the
// server expects to differ from where it actually listens.
val freePort = java.net.ServerSocket(0).use { it.localPort }
val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
relay = RelayEngine(url = url)
server = server =
KtorRelay( KtorRelay(
relay = relay, relay = relay,
host = "127.0.0.1", host = "127.0.0.1",
port = 0, port = freePort,
adminPubkeys = setOf(admin.pubKey), adminPubkeys = setOf(admin.pubKey),
).start() ).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -196,13 +202,16 @@ class Nip86EndToEndTest {
} }
@Test @Test
fun adminEndpointDisabledWhenNoPubkeysConfigured() = fun adminEndpointRejectsValidTokenAsNotAdminWhenNoPubkeysConfigured() =
runBlocking { runBlocking {
// Spin up a *separate* server with no admin pubkeys. // Spin up a *separate* server with no admin pubkeys. The
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() // POST route is still mounted (uniform code path); every
val openRelay = RelayEngine(url = placeholder) // request just fails the empty allow-list check → 403.
val freePort = java.net.ServerSocket(0).use { it.localPort }
val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
val openRelay = RelayEngine(url = url)
val openServer = val openServer =
KtorRelay(openRelay, host = "127.0.0.1", port = 0).start() KtorRelay(openRelay, host = "127.0.0.1", port = freePort).start()
try { try {
val openHttpUrl = openServer.url.replace("ws://", "http://") val openHttpUrl = openServer.url.replace("ws://", "http://")
val body = val body =
@@ -33,31 +33,41 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
* implementation can plug its HTTP framework in without re-deriving * implementation can plug its HTTP framework in without re-deriving
* the auth / parse / dispatch / serialize sequence: * the auth / parse / dispatch / serialize sequence:
* *
* 1. **Gate.** If `server.isEnabled()` is false (empty admin list), * 1. **Size cap.** If `body.size > maxBodyBytes`, reject with
* 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 * [Response.PayloadTooLarge]. Adapters MUST also bound the read
* itself — NIP-98 forces us to compute sha256 over the full * itself — NIP-98 forces us to compute sha256 over the full
* body for signature binding, so an unbounded read is a pre-auth * body for signature binding, so an unbounded read is a pre-auth
* OOM vector. This check is defense-in-depth. * OOM vector. This check is defense-in-depth.
* 3. **Verify.** Run [Nip98AuthVerifier.verify] (method = `POST`, * 2. **Verify.** Run [Nip98AuthVerifier.verify] with method = `POST`
* given [url] and [body]). Missing → [Response.MissingAuth] * and the handler's configured [publicUrl]. The NIP-98 token's
* signed `u` tag MUST match [publicUrl] — that's how the relay
* proves the admin token was minted for *this* relay and not
* replayed from another one. Missing header → [Response.MissingAuth]
* (→ 401 + `WWW-Authenticate: Nostr`). Malformed → * (→ 401 + `WWW-Authenticate: Nostr`). Malformed →
* [Response.BadAuth] (→ 401). * [Response.BadAuth] (→ 401).
* 4. **Admin check.** If the verified pubkey is not in * 3. **Admin check.** If the verified pubkey is not in
* `server.isAuthorized`, reject with [Response.NotAdmin] (→ 403). * `server.isAuthorized`, reject with [Response.NotAdmin] (→ 403).
* 5. **Parse.** Decode [Nip86Request] from the body bytes. Invalid * 4. **Parse.** Decode [Nip86Request] from the body bytes. Invalid
* → [Response.BadRequest] (→ 400). * → [Response.BadRequest] (→ 400).
* 6. **Dispatch.** Call `server.dispatch(pubkey, req)` and wrap the * 5. **Dispatch.** Call `server.dispatch(pubkey, req)` and wrap the
* [Nip86Response] in [Response.Ok], pre-serialized as JSON ready * [Nip86Response] in [Response.Ok], pre-serialized as JSON ready
* to write to the wire with `Content-Type: application/nostr+json+rpc`. * to write to the wire with `Content-Type: application/nostr+json+rpc`.
* *
* Transport-agnostic: takes raw primitives ([authHeader], [url], * An empty admin allow-list is not a special case: the handler runs
* [body]) and returns a sealed [Response]. The adapter maps each * the same flow, the pubkey check (step 3) just always fails with
* variant to its framework's status-code / header API — for Ktor, * [Response.NotAdmin]. Transports therefore wire the route the same
* see `geode/server/Nip86HttpRoute`. * way regardless of whether admin happens to be enabled — uniform
* code path, uniform error model.
* *
* Transport-agnostic: takes raw primitives ([authHeader], [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 publicUrl Canonical URL admin tokens must sign for —
* typically `https://relay.example.com/`. Required (non-blank)
* precisely because the alternative ("trust the `Host` header")
* lets an attacker bind their signed admin token to any URL.
* @param maxBodyBytes Defense-in-depth cap. NIP-86 RPC payloads are * @param maxBodyBytes Defense-in-depth cap. NIP-86 RPC payloads are
* a few hundred bytes; the 1 MiB default is ~1000× any plausible * a few hundred bytes; the 1 MiB default is ~1000× any plausible
* request, but small enough that an attacker can't OOM the relay * request, but small enough that an attacker can't OOM the relay
@@ -65,19 +75,22 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
*/ */
class Nip86HttpHandler( class Nip86HttpHandler(
private val server: Nip86Server, private val server: Nip86Server,
private val publicUrl: String,
private val verifier: Nip98AuthVerifier = Nip98AuthVerifier(), private val verifier: Nip98AuthVerifier = Nip98AuthVerifier(),
val maxBodyBytes: Int = DEFAULT_MAX_BODY_BYTES, val maxBodyBytes: Int = DEFAULT_MAX_BODY_BYTES,
) { ) {
init {
require(publicUrl.isNotBlank()) { "publicUrl must not be blank" }
}
suspend fun handle( suspend fun handle(
authHeader: String?, authHeader: String?,
url: String,
body: ByteArray, body: ByteArray,
): Response { ): Response {
if (!server.isEnabled()) return Response.Disabled
if (body.size > maxBodyBytes) return Response.PayloadTooLarge(maxBodyBytes) if (body.size > maxBodyBytes) return Response.PayloadTooLarge(maxBodyBytes)
val pubkey = val pubkey =
when (val v = verifier.verify(authHeader, method = "POST", url = url, body = body)) { when (val v = verifier.verify(authHeader, method = "POST", url = publicUrl, body = body)) {
is Nip98AuthVerifier.Result.Verified -> v.pubkey is Nip98AuthVerifier.Result.Verified -> v.pubkey
Nip98AuthVerifier.Result.Missing -> return Response.MissingAuth Nip98AuthVerifier.Result.Missing -> return Response.MissingAuth
is Nip98AuthVerifier.Result.Malformed -> return Response.BadAuth(v.reason) is Nip98AuthVerifier.Result.Malformed -> return Response.BadAuth(v.reason)
@@ -102,17 +115,17 @@ class Nip86HttpHandler(
* *
* | Variant | HTTP | Notes | * | Variant | HTTP | Notes |
* |---|---|---| * |---|---|---|
* | [Disabled] | 403 | "admin API not enabled" |
* | [PayloadTooLarge] | 413 | adapter SHOULD bound the read itself | * | [PayloadTooLarge] | 413 | adapter SHOULD bound the read itself |
* | [MissingAuth] | 401 | send `WWW-Authenticate: Nostr` | * | [MissingAuth] | 401 | send `WWW-Authenticate: Nostr` |
* | [BadAuth] | 401 | NIP-98 signature/binding failed | * | [BadAuth] | 401 | NIP-98 signature/binding failed (incl. URL mismatch) |
* | [NotAdmin] | 403 | verified pubkey not on allow-list | * | [NotAdmin] | 403 | verified pubkey not on allow-list |
* | [BadRequest] | 400 | body wasn't a valid `Nip86Request` | * | [BadRequest] | 400 | body wasn't a valid `Nip86Request` |
* | [Ok] | 200 | `Content-Type: application/nostr+json+rpc`; write [Ok.json] | * | [Ok] | 200 | `Content-Type: application/nostr+json+rpc`; write [Ok.json] |
*
* "Admin endpoint disabled" is **not** a [Response] variant — the
* adapter must check that case before instantiating a handler.
*/ */
sealed interface Response { sealed interface Response {
data object Disabled : Response
data class PayloadTooLarge( data class PayloadTooLarge(
val cap: Int, val cap: Int,
) : Response ) : Response
@@ -54,10 +54,9 @@ import kotlinx.serialization.json.int
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`), * whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
* and in-process tests can build a [Nip86Request] directly. The * and in-process tests can build a [Nip86Request] directly. The
* [allowList] check is enforced inside [dispatch] so no caller can * [allowList] check is enforced inside [dispatch] so no caller can
* accidentally bypass it; transports also have [isEnabled] and * accidentally bypass it; transports may also use [isAuthorized] to
* [isAuthorized] to distinguish "endpoint disabled" (empty list, e.g. * make the decision before dispatching (e.g. to short-circuit the
* 403 + "not enabled") from "not allowed" (valid token but * request parse).
* unrecognized pubkey, e.g. 403 + "not on admin list").
* *
* [supportedMethods] is the canonical list this server actually * [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86 * implements; methods returned outside of it are no-ops and a NIP-86
@@ -91,17 +90,15 @@ class Nip86Server(
*/ */
private val onBan: suspend (Filter) -> Unit = {}, private val onBan: suspend (Filter) -> Unit = {},
/** /**
* Pubkeys allowed to invoke admin RPCs. Empty disables the admin * Pubkeys allowed to invoke admin RPCs. Empty effectively disables
* API entirely — [isEnabled] returns false and [dispatch] rejects * the admin API: [isAuthorized] returns false for every pubkey
* everything. Compared case-insensitively (lowercased on entry). * and [dispatch] rejects everything as `not authorized`. Compared
* case-insensitively (lowercased on entry).
*/ */
allowList: Set<HexKey> = emptySet(), allowList: Set<HexKey> = emptySet(),
) { ) {
private val allowList: Set<HexKey> = allowList.mapTo(HashSet()) { it.lowercase() } private val allowList: Set<HexKey> = 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. */ /** True when [pubkey] is on the admin allow-list. Case-insensitive. */
fun isAuthorized(pubkey: HexKey): Boolean = pubkey.lowercase() in allowList fun isAuthorized(pubkey: HexKey): Boolean = pubkey.lowercase() in allowList
@@ -138,10 +135,9 @@ class Nip86Server(
* WS, or in-process trust). * WS, or in-process trust).
* *
* If [pubkey] is not in [allowList], returns a `not authorized` * If [pubkey] is not in [allowList], returns a `not authorized`
* error response without executing anything. Transports that * error response without executing anything. Transports that want
* surface different HTTP statuses for "disabled" vs "not on list" * to short-circuit before parsing the request can pre-check via
* should pre-check via [isEnabled] / [isAuthorized] instead of * [isAuthorized].
* relying on this string.
*/ */
suspend fun dispatch( suspend fun dispatch(
pubkey: HexKey, pubkey: HexKey,
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
@@ -41,9 +42,9 @@ class Nip86HttpHandlerTest {
private val verifier = Nip98AuthVerifier(now = { now }) private val verifier = Nip98AuthVerifier(now = { now })
private val adminSigner = NostrSignerSync(KeyPair()) private val adminSigner = NostrSignerSync(KeyPair())
private val intruderSigner = NostrSignerSync(KeyPair()) private val intruderSigner = NostrSignerSync(KeyPair())
private val url = "http://relay.example.com/" private val publicUrl = "https://relay.example.com/"
private fun handlerWith(adminInList: Boolean = true): Pair<Nip86HttpHandler, Nip86Server> { private fun handler(): Nip86HttpHandler {
val server = val server =
Nip86Server( Nip86Server(
banStore = BanStore(), banStore = BanStore(),
@@ -57,13 +58,15 @@ class Nip86HttpHandlerTest {
doc = info doc = info
} }
}, },
allowList = if (adminInList) setOf(adminSigner.pubKey) else emptySet(), allowList = setOf(adminSigner.pubKey),
) )
return Nip86HttpHandler(server, verifier) to server return Nip86HttpHandler(server, publicUrl, verifier)
} }
/** Build a NIP-98 token for [url] (which may or may not match [publicUrl]). */
private fun signedHeader( private fun signedHeader(
body: ByteArray, body: ByteArray,
url: String = publicUrl,
signer: NostrSignerSync = adminSigner, signer: NostrSignerSync = adminSigner,
): String { ): String {
val template = HTTPAuthorizationEvent.build(url = url, method = "POST", file = body, createdAt = now) val template = HTTPAuthorizationEvent.build(url = url, method = "POST", file = body, createdAt = now)
@@ -74,32 +77,72 @@ class Nip86HttpHandlerTest {
get() = JsonMapper.toJson(Nip86Request(method = Nip86Method.SUPPORTED_METHODS)).encodeToByteArray() get() = JsonMapper.toJson(Nip86Request(method = Nip86Method.SUPPORTED_METHODS)).encodeToByteArray()
@Test @Test
fun disabledWhenAllowListIsEmpty() { fun emptyAllowListRejectsValidlySignedRequestsAsNotAdmin() {
// No special "disabled" handling — the handler runs the same
// flow and the allow-list check (which always fails for an
// empty list) returns NotAdmin.
runBlocking { runBlocking {
val (handler, _) = handlerWith(adminInList = false) val disabledServer =
Nip86Server(
banStore = BanStore(),
infoHolder =
object : Nip86Server.InfoHolder {
private var doc = Nip11RelayInformation()
override fun get() = doc
override fun set(info: Nip11RelayInformation) {
doc = info
}
},
// no allowList
)
val h = Nip86HttpHandler(disabledServer, publicUrl, verifier)
val body = supportedMethodsBody val body = supportedMethodsBody
val r = handler.handle(signedHeader(body), url, body) val header = signedHeader(body) // signed by adminSigner — but list is empty
assertIs<Nip86HttpHandler.Response.Disabled>(r) val r = h.handle(header, body)
assertIs<Nip86HttpHandler.Response.NotAdmin>(r)
}
}
@Test
fun rejectsConstructionWithBlankPublicUrl() {
val server =
Nip86Server(
banStore = BanStore(),
infoHolder =
object : Nip86Server.InfoHolder {
private var doc = Nip11RelayInformation()
override fun get() = doc
override fun set(info: Nip11RelayInformation) {
doc = info
}
},
allowList = setOf(adminSigner.pubKey),
)
assertFailsWith<IllegalArgumentException> {
Nip86HttpHandler(server, publicUrl = " ")
} }
} }
@Test @Test
fun payloadTooLargeBeforeAuthCheck() { fun payloadTooLargeBeforeAuthCheck() {
runBlocking { runBlocking {
val (handler, _) = handlerWith() val h = handler()
val oversized = ByteArray(handler.maxBodyBytes + 1) val oversized = ByteArray(h.maxBodyBytes + 1)
// No need for a valid signature — size check fires first. // No need for a valid signature — size check fires first.
val r = handler.handle("anything", url, oversized) val r = h.handle("anything", oversized)
assertIs<Nip86HttpHandler.Response.PayloadTooLarge>(r) assertIs<Nip86HttpHandler.Response.PayloadTooLarge>(r)
assertEquals(handler.maxBodyBytes, r.cap) assertEquals(h.maxBodyBytes, r.cap)
} }
} }
@Test @Test
fun missingAuthHeader() { fun missingAuthHeader() {
runBlocking { runBlocking {
val (handler, _) = handlerWith() val r = handler().handle(authHeader = null, body = supportedMethodsBody)
val r = handler.handle(authHeader = null, url = url, body = supportedMethodsBody)
assertIs<Nip86HttpHandler.Response.MissingAuth>(r) assertIs<Nip86HttpHandler.Response.MissingAuth>(r)
} }
} }
@@ -107,20 +150,32 @@ class Nip86HttpHandlerTest {
@Test @Test
fun malformedAuthIsBadAuth() { fun malformedAuthIsBadAuth() {
runBlocking { runBlocking {
val (handler, _) = handlerWith() val r = handler().handle("Bearer not-a-nostr-token", supportedMethodsBody)
val r = handler.handle("Bearer not-a-nostr-token", url, supportedMethodsBody)
assertIs<Nip86HttpHandler.Response.BadAuth>(r) assertIs<Nip86HttpHandler.Response.BadAuth>(r)
assertTrue(r.reason.contains("Nostr")) assertTrue(r.reason.contains("Nostr"))
} }
} }
@Test
fun urlMismatchInTokenIsBadAuth() {
// Token signed for a different URL — the URL-binding check
// fires and the relay refuses. This is the attack the
// publicUrl requirement is closing off.
runBlocking {
val body = supportedMethodsBody
val header = signedHeader(body, url = "https://other-relay.example.com/")
val r = handler().handle(header, body)
assertIs<Nip86HttpHandler.Response.BadAuth>(r)
assertTrue(r.reason.contains("url mismatch"))
}
}
@Test @Test
fun verifiedButNotAdminIsRejectedAsNotAdmin() { fun verifiedButNotAdminIsRejectedAsNotAdmin() {
runBlocking { runBlocking {
val (handler, _) = handlerWith() // admin is adminSigner only
val body = supportedMethodsBody val body = supportedMethodsBody
val header = signedHeader(body, signer = intruderSigner) val header = signedHeader(body, signer = intruderSigner)
val r = handler.handle(header, url, body) val r = handler().handle(header, body)
assertIs<Nip86HttpHandler.Response.NotAdmin>(r) assertIs<Nip86HttpHandler.Response.NotAdmin>(r)
} }
} }
@@ -128,10 +183,9 @@ class Nip86HttpHandlerTest {
@Test @Test
fun verifiedAdminButBadJsonBodyIsBadRequest() { fun verifiedAdminButBadJsonBodyIsBadRequest() {
runBlocking { runBlocking {
val (handler, _) = handlerWith()
val body = "not valid json {".encodeToByteArray() val body = "not valid json {".encodeToByteArray()
val header = signedHeader(body) val header = signedHeader(body)
val r = handler.handle(header, url, body) val r = handler().handle(header, body)
assertIs<Nip86HttpHandler.Response.BadRequest>(r) assertIs<Nip86HttpHandler.Response.BadRequest>(r)
} }
} }
@@ -139,10 +193,9 @@ class Nip86HttpHandlerTest {
@Test @Test
fun verifiedAdminWithValidRequestDispatches() { fun verifiedAdminWithValidRequestDispatches() {
runBlocking { runBlocking {
val (handler, _) = handlerWith()
val body = supportedMethodsBody val body = supportedMethodsBody
val header = signedHeader(body) val header = signedHeader(body)
val r = handler.handle(header, url, body) val r = handler().handle(header, body)
val ok = assertIs<Nip86HttpHandler.Response.Ok>(r) val ok = assertIs<Nip86HttpHandler.Response.Ok>(r)
assertEquals(adminSigner.pubKey, ok.pubkey) assertEquals(adminSigner.pubKey, ok.pubkey)
assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method) assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method)
@@ -188,7 +188,7 @@ class Nip86ServerTest {
val (server, _, _) = fixture() val (server, _, _) = fixture()
val resp = server.dispatch(admin, Nip86Request(method = "frobnicate")) val resp = server.dispatch(admin, Nip86Request(method = "frobnicate"))
assertNotNull(resp.error) assertNotNull(resp.error)
assertTrue(resp.error!!.contains("frobnicate")) assertTrue(resp.error.contains("frobnicate"))
} }
} }
@@ -199,7 +199,7 @@ class Nip86ServerTest {
// banpubkey requires at least one positional param. // banpubkey requires at least one positional param.
val resp = server.dispatch(admin, Nip86Request(method = Nip86Method.BAN_PUBKEY)) val resp = server.dispatch(admin, Nip86Request(method = Nip86Method.BAN_PUBKEY))
assertNotNull(resp.error) assertNotNull(resp.error)
assertTrue(resp.error!!.startsWith("invalid params")) assertTrue(resp.error.startsWith("invalid params"))
} }
} }
@@ -210,18 +210,17 @@ class Nip86ServerTest {
val intruder = "e".repeat(64) val intruder = "e".repeat(64)
val resp = server.dispatch(intruder, Nip86Request.banPubkey(pk, "spam")) val resp = server.dispatch(intruder, Nip86Request.banPubkey(pk, "spam"))
assertNotNull(resp.error) assertNotNull(resp.error)
assertTrue(resp.error!!.contains("not on the admin list")) assertTrue(resp.error.contains("not on the admin list"))
// And no state was mutated. // And no state was mutated.
assertTrue(!banStore.isBanned(pk)) assertTrue(!banStore.isBanned(pk))
} }
} }
@Test @Test
fun emptyAllowListDisablesTheServer() { fun emptyAllowListRejectsEveryPubkey() {
val store = BanStore() val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "x")) val holder = Holder(Nip11RelayInformation(name = "x"))
val server = Nip86Server(banStore = store, infoHolder = holder) // no allowList val server = Nip86Server(banStore = store, infoHolder = holder) // no allowList
assertEquals(false, server.isEnabled())
assertEquals(false, server.isAuthorized(admin)) assertEquals(false, server.isAuthorized(admin))
} }