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