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