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:
+33
-20
@@ -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
|
||||
|
||||
+10
-14
@@ -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,
|
||||
|
||||
+75
-22
@@ -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)
|
||||
|
||||
+4
-5
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user