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
@@ -33,31 +33,41 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
* 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
* 1. **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]
* 2. **Verify.** Run [Nip98AuthVerifier.verify] with method = `POST`
* 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 →
* [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).
* 5. **Parse.** Decode [Nip86Request] from the body bytes. Invalid
* 4. **Parse.** Decode [Nip86Request] from the body bytes. Invalid
* → [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
* 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`.
* An empty admin allow-list is not a special case: the handler runs
* the same flow, the pubkey check (step 3) just always fails with
* [Response.NotAdmin]. Transports therefore wire the route the same
* 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
* a few hundred bytes; the 1 MiB default is ~1000× any plausible
* request, but small enough that an attacker can't OOM the relay
@@ -65,19 +75,22 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
*/
class Nip86HttpHandler(
private val server: Nip86Server,
private val publicUrl: String,
private val verifier: Nip98AuthVerifier = Nip98AuthVerifier(),
val maxBodyBytes: Int = DEFAULT_MAX_BODY_BYTES,
) {
init {
require(publicUrl.isNotBlank()) { "publicUrl must not be blank" }
}
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)) {
when (val v = verifier.verify(authHeader, method = "POST", url = publicUrl, body = body)) {
is Nip98AuthVerifier.Result.Verified -> v.pubkey
Nip98AuthVerifier.Result.Missing -> return Response.MissingAuth
is Nip98AuthVerifier.Result.Malformed -> return Response.BadAuth(v.reason)
@@ -102,17 +115,17 @@ class Nip86HttpHandler(
*
* | 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 |
* | [BadAuth] | 401 | NIP-98 signature/binding failed (incl. URL mismatch) |
* | [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] |
*
* "Admin endpoint disabled" is **not** a [Response] variant — the
* adapter must check that case before instantiating a handler.
*/
sealed interface Response {
data object Disabled : Response
data class PayloadTooLarge(
val cap: Int,
) : Response
@@ -54,10 +54,9 @@ import kotlinx.serialization.json.int
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
* 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").
* accidentally bypass it; transports may also use [isAuthorized] to
* make the decision before dispatching (e.g. to short-circuit the
* request parse).
*
* [supportedMethods] is the canonical list this server actually
* 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 = {},
/**
* 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).
* Pubkeys allowed to invoke admin RPCs. Empty effectively disables
* the admin API: [isAuthorized] returns false for every pubkey
* and [dispatch] rejects everything as `not authorized`. Compared
* case-insensitively (lowercased on entry).
*/
allowList: Set<HexKey> = emptySet(),
) {
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. */
fun isAuthorized(pubkey: HexKey): Boolean = pubkey.lowercase() in allowList
@@ -138,10 +135,9 @@ class Nip86Server(
* 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.
* error response without executing anything. Transports that want
* to short-circuit before parsing the request can pre-check via
* [isAuthorized].
*/
suspend fun dispatch(
pubkey: HexKey,
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@@ -41,9 +42,9 @@ class Nip86HttpHandlerTest {
private val verifier = Nip98AuthVerifier(now = { now })
private val adminSigner = 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 =
Nip86Server(
banStore = BanStore(),
@@ -57,13 +58,15 @@ class Nip86HttpHandlerTest {
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(
body: ByteArray,
url: String = publicUrl,
signer: NostrSignerSync = adminSigner,
): String {
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()
@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 {
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 r = handler.handle(signedHeader(body), url, body)
assertIs<Nip86HttpHandler.Response.Disabled>(r)
val header = signedHeader(body) // signed by adminSigner — but list is empty
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
fun payloadTooLargeBeforeAuthCheck() {
runBlocking {
val (handler, _) = handlerWith()
val oversized = ByteArray(handler.maxBodyBytes + 1)
val h = handler()
val oversized = ByteArray(h.maxBodyBytes + 1)
// 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)
assertEquals(handler.maxBodyBytes, r.cap)
assertEquals(h.maxBodyBytes, r.cap)
}
}
@Test
fun missingAuthHeader() {
runBlocking {
val (handler, _) = handlerWith()
val r = handler.handle(authHeader = null, url = url, body = supportedMethodsBody)
val r = handler().handle(authHeader = null, body = supportedMethodsBody)
assertIs<Nip86HttpHandler.Response.MissingAuth>(r)
}
}
@@ -107,20 +150,32 @@ class Nip86HttpHandlerTest {
@Test
fun malformedAuthIsBadAuth() {
runBlocking {
val (handler, _) = handlerWith()
val r = handler.handle("Bearer not-a-nostr-token", url, supportedMethodsBody)
val r = handler().handle("Bearer not-a-nostr-token", supportedMethodsBody)
assertIs<Nip86HttpHandler.Response.BadAuth>(r)
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
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)
val r = handler().handle(header, body)
assertIs<Nip86HttpHandler.Response.NotAdmin>(r)
}
}
@@ -128,10 +183,9 @@ class Nip86HttpHandlerTest {
@Test
fun verifiedAdminButBadJsonBodyIsBadRequest() {
runBlocking {
val (handler, _) = handlerWith()
val body = "not valid json {".encodeToByteArray()
val header = signedHeader(body)
val r = handler.handle(header, url, body)
val r = handler().handle(header, body)
assertIs<Nip86HttpHandler.Response.BadRequest>(r)
}
}
@@ -139,10 +193,9 @@ class Nip86HttpHandlerTest {
@Test
fun verifiedAdminWithValidRequestDispatches() {
runBlocking {
val (handler, _) = handlerWith()
val body = supportedMethodsBody
val header = signedHeader(body)
val r = handler.handle(header, url, body)
val r = handler().handle(header, body)
val ok = assertIs<Nip86HttpHandler.Response.Ok>(r)
assertEquals(adminSigner.pubKey, ok.pubkey)
assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method)
@@ -188,7 +188,7 @@ class Nip86ServerTest {
val (server, _, _) = fixture()
val resp = server.dispatch(admin, Nip86Request(method = "frobnicate"))
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.
val resp = server.dispatch(admin, Nip86Request(method = Nip86Method.BAN_PUBKEY))
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 resp = server.dispatch(intruder, Nip86Request.banPubkey(pk, "spam"))
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.
assertTrue(!banStore.isBanned(pk))
}
}
@Test
fun emptyAllowListDisablesTheServer() {
fun emptyAllowListRejectsEveryPubkey() {
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))
}