feat(relay): NIP-86 relay management API
Implements the operator-facing JSON-RPC admin protocol on the relay
side, layered on top of the existing NIP-98 HTTP-Auth + NIP-86 wire
types in :quartz. Same path as the NIP-01 WebSocket and NIP-11 GET —
HTTP POST with Content-Type application/nostr+json+rpc, gated by a
NIP-98 Authorization header signed by an operator-listed pubkey.
Architecture:
- BanStore — concurrent in-memory state for runtime
pubkey/event/kind ban + allow lists.
- DynamicBanPolicy — IRelayPolicy that consults BanStore on every
EVENT; auto-prepended to every Relay's policy
stack so admin actions take effect without a
restart.
- Nip98AuthVerifier — parses `Authorization: Nostr <base64-event>`,
verifies kind 27235 + Schnorr sig + ±60 s
clock skew + method/url/payload-hash match.
- Nip86Server — JSON-RPC dispatcher. Mutates BanStore for
ban/allow/list methods, mutates the live
RelayInfo doc for changerelayname/desc/icon
(Relay.info is now @Volatile var, mutation
through Relay.updateInfo).
- LocalRelayServer — adds POST handler at the relay path:
403 if no admin pubkeys configured,
401 if NIP-98 missing/invalid,
403 if signer's pubkey isn't on the admin list,
200 with Nip86Response otherwise.
- RelayConfig.AdminSection — `[admin].pubkeys = [...]` config key.
- RelayInfo.default() now advertises NIP-86 alongside 1/9/11/40/42/45/50/62.
Methods implemented:
supportedmethods, banpubkey, unbanpubkey, listbannedpubkeys,
allowpubkey, unallowpubkey, listallowedpubkeys,
banevent (also deletes from store), allowevent, listbannedevents,
allowkind, disallowkind, listallowedkinds,
changerelayname, changerelaydescription, changerelayicon.
Tests (28 new):
- BanStoreTest (6) — ban/allow round-trips, case-insensitive
pubkey match, kind allow/deny precedence,
audit-trail listing.
- Nip86ServerTest (8) — every method's accept/reject path.
- Nip98AuthVerifierTest (8) — happy path, missing/wrong scheme,
url/method/payload-hash mismatch,
stale created_at, wrong event kind.
- Nip86EndToEndTest (6) — real HTTP POST through LocalRelayServer:
supportedmethods returns 200,
outsider returns 403, no auth header
returns 401 + WWW-Authenticate, banpubkey
blocks subsequent EVENT publish over WS,
changerelayname flows to NIP-11 GET,
admin endpoint is disabled when no
pubkeys configured.
Total :quartz-relay tests: 87, 0 failures.
This commit is contained in:
@@ -31,6 +31,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.jackson.module.kotlin)
|
implementation(libs.jackson.module.kotlin)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Bundled SQLite driver — Relay's default in-memory EventStore creates
|
// Bundled SQLite driver — Relay's default in-memory EventStore creates
|
||||||
// an in-memory DB at runtime.
|
// an in-memory DB at runtime.
|
||||||
|
|||||||
@@ -60,3 +60,11 @@ require_auth = false
|
|||||||
# pubkey_blacklist = []
|
# pubkey_blacklist = []
|
||||||
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
|
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
|
||||||
# kind_blacklist = [4]
|
# kind_blacklist = [4]
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
|
||||||
|
# accepts HTTP POST application/nostr+json+rpc on the same URL,
|
||||||
|
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of
|
||||||
|
# the listed pubkeys can run admin RPCs (banpubkey / banevent /
|
||||||
|
# changerelayname / …). Empty (the default) disables the endpoint.
|
||||||
|
# pubkeys = ["abcdef...64hex..."]
|
||||||
|
|||||||
@@ -20,8 +20,14 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.relay
|
package com.vitorpamplona.quartz.relay
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
|
||||||
|
import com.vitorpamplona.quartz.relay.admin.Nip86Server
|
||||||
|
import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
@@ -30,11 +36,14 @@ import io.ktor.server.cio.CIO
|
|||||||
import io.ktor.server.cio.CIOApplicationEngine
|
import io.ktor.server.cio.CIOApplicationEngine
|
||||||
import io.ktor.server.engine.embeddedServer
|
import io.ktor.server.engine.embeddedServer
|
||||||
import io.ktor.server.request.header
|
import io.ktor.server.request.header
|
||||||
|
import io.ktor.server.request.receiveChannel
|
||||||
import io.ktor.server.response.respondText
|
import io.ktor.server.response.respondText
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.post
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
import io.ktor.server.websocket.WebSockets
|
import io.ktor.server.websocket.WebSockets
|
||||||
import io.ktor.server.websocket.webSocket
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import io.ktor.utils.io.toByteArray
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import kotlinx.coroutines.channels.consumeEach
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
@@ -72,7 +81,32 @@ class LocalRelayServer(
|
|||||||
* uses Ktor's default (~1 MiB).
|
* uses Ktor's default (~1 MiB).
|
||||||
*/
|
*/
|
||||||
val maxFrameBytes: Long? = null,
|
val maxFrameBytes: Long? = null,
|
||||||
|
/**
|
||||||
|
* Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default)
|
||||||
|
* disables the admin endpoint entirely — POSTs return 403.
|
||||||
|
* Otherwise: HTTP POSTs to [path] with `Content-Type:
|
||||||
|
* application/nostr+json+rpc` are dispatched to [Nip86Server],
|
||||||
|
* gated by NIP-98 HTTP-Auth membership in this set.
|
||||||
|
*/
|
||||||
|
val adminPubkeys: Set<HexKey> = emptySet(),
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Bridges the relay's mutable [RelayInfo] to [Nip86Server.InfoHolder]
|
||||||
|
* so admin RPCs can rewrite the NIP-11 doc atomically.
|
||||||
|
*/
|
||||||
|
private val infoHolder =
|
||||||
|
object : Nip86Server.InfoHolder {
|
||||||
|
override fun get() = relay.info
|
||||||
|
|
||||||
|
override fun set(info: RelayInfo) {
|
||||||
|
relay.updateInfo { info.document }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val nip86 = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
|
||||||
|
private val nip98 = Nip98AuthVerifier()
|
||||||
|
|
||||||
|
private val adminAllowList: Set<HexKey> = adminPubkeys.mapTo(HashSet()) { it.lowercase() }
|
||||||
private var engine: CIOApplicationEngine? = null
|
private var engine: CIOApplicationEngine? = null
|
||||||
private var resolvedPort: Int = -1
|
private var resolvedPort: Int = -1
|
||||||
|
|
||||||
@@ -126,6 +160,11 @@ class LocalRelayServer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// NIP-86: POST application/nostr+json+rpc with a NIP-98
|
||||||
|
// signed Authorization header → JSON-RPC dispatch.
|
||||||
|
post(path) {
|
||||||
|
handleNip86(call)
|
||||||
|
}
|
||||||
webSocket(path) {
|
webSocket(path) {
|
||||||
val session =
|
val session =
|
||||||
relay.server.connect { json ->
|
relay.server.connect { json ->
|
||||||
@@ -190,6 +229,94 @@ class LocalRelayServer(
|
|||||||
resolvedPort = -1
|
resolvedPort = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a NIP-86 admin RPC request:
|
||||||
|
* 1. 403 if no admin pubkey list is configured (endpoint disabled).
|
||||||
|
* 2. 401 if the NIP-98 Authorization header is missing/invalid.
|
||||||
|
* 3. 403 if the verified pubkey isn't in [adminAllowList].
|
||||||
|
* 4. 400 if the body isn't a valid Nip86Request.
|
||||||
|
* 5. 200 with a Nip86Response JSON body otherwise.
|
||||||
|
*
|
||||||
|
* `application/nostr+json+rpc` is the wire content type prescribed
|
||||||
|
* by NIP-86; we send it on responses and accept any body on the
|
||||||
|
* request (the auth event's payload-hash already binds the body).
|
||||||
|
*/
|
||||||
|
private suspend fun handleNip86(call: io.ktor.server.application.ApplicationCall) {
|
||||||
|
if (adminAllowList.isEmpty()) {
|
||||||
|
call.respondText(
|
||||||
|
"NIP-86 management API is not enabled on this relay.",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.Forbidden,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = call.receiveChannel().toByteArray()
|
||||||
|
val authHeader = call.request.header(HttpHeaders.Authorization)
|
||||||
|
// NIP-86 spec: the URL the client signed must be the relay's
|
||||||
|
// canonical http(s) URL, not the WS one. We reconstruct it from
|
||||||
|
// the request so the comparison is symmetric whether the
|
||||||
|
// operator runs the relay behind a reverse proxy or directly.
|
||||||
|
val signedUrl =
|
||||||
|
"http://" +
|
||||||
|
(call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path
|
||||||
|
val verification = nip98.verify(authHeader, method = "POST", url = signedUrl, body = body)
|
||||||
|
|
||||||
|
val pubkey =
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
is Nip98AuthVerifier.Result.Malformed -> {
|
||||||
|
call.respondText(
|
||||||
|
"invalid NIP-98 Authorization: ${verification.reason}",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubkey.lowercase() !in adminAllowList) {
|
||||||
|
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) {
|
||||||
|
call.respondText(
|
||||||
|
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
|
||||||
|
ContentType.Text.Plain,
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val response: Nip86Response = nip86.dispatch(req)
|
||||||
|
call.respondText(
|
||||||
|
JsonMapper.toJson(response),
|
||||||
|
ContentType.parse("application/nostr+json+rpc"),
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort NOTICE to every active client. Failures are
|
* Best-effort NOTICE to every active client. Failures are
|
||||||
* swallowed — a flaky socket on its way out is exactly the case
|
* swallowed — a flaky socket on its way out is exactly the case
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ fun main(args: Array<String>) {
|
|||||||
port = port,
|
port = port,
|
||||||
path = path,
|
path = path,
|
||||||
maxFrameBytes = frameLimit,
|
maxFrameBytes = frameLimit,
|
||||||
|
adminPubkeys = config.admin.pubkeys.toSet(),
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
|||||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.relay.admin.BanStore
|
||||||
|
import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
@@ -51,11 +54,45 @@ import kotlin.coroutines.CoroutineContext
|
|||||||
class Relay(
|
class Relay(
|
||||||
val url: NormalizedRelayUrl,
|
val url: NormalizedRelayUrl,
|
||||||
val store: IEventStore = EventStore(dbName = null, relay = url),
|
val store: IEventStore = EventStore(dbName = null, relay = url),
|
||||||
val info: RelayInfo = RelayInfo.default(url),
|
info: RelayInfo = RelayInfo.default(url),
|
||||||
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
|
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
|
||||||
parentContext: CoroutineContext = SupervisorJob(),
|
parentContext: CoroutineContext = SupervisorJob(),
|
||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
val server = NostrServer(store, policyBuilder, parentContext)
|
/**
|
||||||
|
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
|
||||||
|
* `changerelaydescription`, `changerelayicon`) can swap the doc
|
||||||
|
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
|
||||||
|
* request so changes are visible immediately, no restart needed.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var info: RelayInfo = info
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */
|
||||||
|
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||||
|
info = RelayInfo(transform(info.document))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
|
||||||
|
* [admin.Nip86Server] mutate this; the policy stack consults it on
|
||||||
|
* every accept call via [DynamicBanPolicy].
|
||||||
|
*/
|
||||||
|
val banStore: BanStore = BanStore()
|
||||||
|
|
||||||
|
val server =
|
||||||
|
NostrServer(
|
||||||
|
store,
|
||||||
|
// Always prepend a DynamicBanPolicy so NIP-86 admin actions
|
||||||
|
// bite. When the operator-supplied builder returns
|
||||||
|
// [EmptyPolicy] we use the dynamic policy alone; otherwise
|
||||||
|
// we stack them so both layers must accept.
|
||||||
|
policyBuilder = {
|
||||||
|
val user = policyBuilder()
|
||||||
|
if (user === EmptyPolicy) DynamicBanPolicy(banStore) else user + DynamicBanPolicy(banStore)
|
||||||
|
},
|
||||||
|
parentContext,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inserts events directly into the underlying store, bypassing the wire protocol.
|
* Inserts events directly into the underlying store, bypassing the wire protocol.
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ data class RelayInfo(
|
|||||||
// Currently implemented: NIP-01 (basic), NIP-09 (deletion via
|
// Currently implemented: NIP-01 (basic), NIP-09 (deletion via
|
||||||
// DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration
|
// DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration
|
||||||
// via ExpirationModule), NIP-42 (AUTH — when policy enables),
|
// via ExpirationModule), NIP-42 (AUTH — when policy enables),
|
||||||
// NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish).
|
// NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish),
|
||||||
supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62"),
|
// NIP-86 (relay management API — when admin pubkeys are configured).
|
||||||
|
supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable, thread-safe runtime state for the NIP-86 management API.
|
||||||
|
*
|
||||||
|
* Each entry carries an optional reason string so list-* RPCs can echo
|
||||||
|
* back why an admin took the action — useful for audit trails.
|
||||||
|
*
|
||||||
|
* Today the state is in-memory only; a process restart wipes the bans.
|
||||||
|
* Wiring a persistent backend is a separate concern (a JSON file
|
||||||
|
* snapshot on each mutation, or a small SQLite table) and can be
|
||||||
|
* layered on by replacing this class behind the [DynamicBanPolicy]
|
||||||
|
* interface.
|
||||||
|
*/
|
||||||
|
class BanStore {
|
||||||
|
/**
|
||||||
|
* Pubkeys whose events the relay rejects. Compared case-insensitive
|
||||||
|
* (lowercased on insert / lookup) so an admin pasting a hex pubkey
|
||||||
|
* with mixed case still works. Empty-string value means "no
|
||||||
|
* reason given" — `ConcurrentHashMap` rejects nulls.
|
||||||
|
*/
|
||||||
|
private val bannedPubkeys = ConcurrentHashMap<HexKey, String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pubkeys explicitly allowed. When non-empty, this acts as a
|
||||||
|
* whitelist: events from any pubkey not on the list are rejected.
|
||||||
|
*/
|
||||||
|
private val allowedPubkeys = ConcurrentHashMap<HexKey, String>()
|
||||||
|
|
||||||
|
/** Event ids the relay refuses to store/replay. */
|
||||||
|
private val bannedEventIds = ConcurrentHashMap<HexKey, String>()
|
||||||
|
|
||||||
|
private fun reasonOrEmpty(s: String?): String = s ?: ""
|
||||||
|
|
||||||
|
private fun nullIfEmpty(s: String): String? = s.ifEmpty { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed kinds. When non-empty, events whose kind is not in the
|
||||||
|
* list are rejected.
|
||||||
|
*/
|
||||||
|
private val allowedKinds = ConcurrentHashMap.newKeySet<Int>()
|
||||||
|
|
||||||
|
/** Disallowed kinds. Always blocks regardless of [allowedKinds]. */
|
||||||
|
private val disallowedKinds = ConcurrentHashMap.newKeySet<Int>()
|
||||||
|
|
||||||
|
// -- Pubkey ban list -----------------------------------------------------
|
||||||
|
|
||||||
|
fun banPubkey(
|
||||||
|
pubkey: HexKey,
|
||||||
|
reason: String? = null,
|
||||||
|
) {
|
||||||
|
bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbanPubkey(pubkey: HexKey) {
|
||||||
|
bannedPubkeys.remove(pubkey.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase())
|
||||||
|
|
||||||
|
fun listBannedPubkeys(): List<Pair<HexKey, String?>> = bannedPubkeys.entries.map { it.key to nullIfEmpty(it.value) }
|
||||||
|
|
||||||
|
// -- Pubkey allow list ---------------------------------------------------
|
||||||
|
|
||||||
|
fun allowPubkey(
|
||||||
|
pubkey: HexKey,
|
||||||
|
reason: String? = null,
|
||||||
|
) {
|
||||||
|
allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unallowPubkey(pubkey: HexKey) {
|
||||||
|
allowedPubkeys.remove(pubkey.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase())
|
||||||
|
|
||||||
|
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> = allowedPubkeys.entries.map { it.key to nullIfEmpty(it.value) }
|
||||||
|
|
||||||
|
fun hasAllowList(): Boolean = allowedPubkeys.isNotEmpty()
|
||||||
|
|
||||||
|
// -- Event id ban list ---------------------------------------------------
|
||||||
|
|
||||||
|
fun banEvent(
|
||||||
|
eventId: HexKey,
|
||||||
|
reason: String? = null,
|
||||||
|
) {
|
||||||
|
bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
|
||||||
|
fun allowEvent(eventId: HexKey) {
|
||||||
|
bannedEventIds.remove(eventId.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase())
|
||||||
|
|
||||||
|
fun listBannedEvents(): List<Pair<HexKey, String?>> = bannedEventIds.entries.map { it.key to nullIfEmpty(it.value) }
|
||||||
|
|
||||||
|
// -- Kind allow / deny --------------------------------------------------
|
||||||
|
|
||||||
|
fun allowKind(kind: Int) {
|
||||||
|
allowedKinds.add(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disallowKind(kind: Int) {
|
||||||
|
disallowedKinds.add(kind)
|
||||||
|
// Disallowing a kind implicitly removes it from the allow list.
|
||||||
|
allowedKinds.remove(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listAllowedKinds(): List<Int> = allowedKinds.sorted()
|
||||||
|
|
||||||
|
fun listDisallowedKinds(): List<Int> = disallowedKinds.sorted()
|
||||||
|
|
||||||
|
fun isKindAllowed(kind: Int): Boolean {
|
||||||
|
if (kind in disallowedKinds) return false
|
||||||
|
if (allowedKinds.isEmpty()) return true
|
||||||
|
return kind in allowedKinds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||||
|
import com.vitorpamplona.quartz.relay.policies.PassThroughPolicy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the live [BanStore] on every EVENT and rejects events that
|
||||||
|
* violate any of: banned-event-id, banned-pubkey, missing from a
|
||||||
|
* non-empty allow list, or kind disallowed / not in the kind allow
|
||||||
|
* list.
|
||||||
|
*
|
||||||
|
* This is the runtime-mutable counterpart of the static
|
||||||
|
* [com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy] +
|
||||||
|
* [com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy] —
|
||||||
|
* both sets compose: the event must clear both layers. NIP-86 admin
|
||||||
|
* RPC mutations land here; the static policies stay frozen at
|
||||||
|
* boot-time config values.
|
||||||
|
*/
|
||||||
|
class DynamicBanPolicy(
|
||||||
|
val banStore: BanStore,
|
||||||
|
) : PassThroughPolicy() {
|
||||||
|
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||||
|
val ev = cmd.event
|
||||||
|
if (banStore.isBannedEvent(ev.id)) {
|
||||||
|
return PolicyResult.Rejected("blocked: event id is banned")
|
||||||
|
}
|
||||||
|
if (banStore.isBanned(ev.pubKey)) {
|
||||||
|
return PolicyResult.Rejected("blocked: pubkey is banned")
|
||||||
|
}
|
||||||
|
if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) {
|
||||||
|
return PolicyResult.Rejected("blocked: pubkey is not on the allow list")
|
||||||
|
}
|
||||||
|
if (!banStore.isKindAllowed(ev.kind)) {
|
||||||
|
return PolicyResult.Rejected("blocked: kind ${ev.kind} not allowed")
|
||||||
|
}
|
||||||
|
return PolicyResult.Accepted(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
|
||||||
|
import com.vitorpamplona.quartz.relay.RelayInfo
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-86 RPC dispatcher. Holds the [BanStore] (mutated by ban/allow
|
||||||
|
* methods), the live [RelayInfo] handle (mutated by `changerelay*`
|
||||||
|
* methods, which atomically swap the doc), and the underlying
|
||||||
|
* [IEventStore] so `banevent` can also delete the offending event.
|
||||||
|
*
|
||||||
|
* The dispatcher is transport-agnostic — `LocalRelayServer` calls
|
||||||
|
* [dispatch] from its HTTP route, but the same handler also works for
|
||||||
|
* in-process tests that build a [Nip86Request] directly.
|
||||||
|
*
|
||||||
|
* [supportedMethods] is the canonical list this server actually
|
||||||
|
* implements; methods returned outside of it are no-ops and a NIP-86
|
||||||
|
* client must not advertise them.
|
||||||
|
*/
|
||||||
|
class Nip86Server(
|
||||||
|
val banStore: BanStore,
|
||||||
|
/**
|
||||||
|
* Read-write access to the relay's NIP-11 info doc. The dispatcher
|
||||||
|
* mutates this when an admin calls `changerelayname` /
|
||||||
|
* `changerelaydescription` / `changerelayicon`. Relay code reading
|
||||||
|
* the doc (e.g. the NIP-11 endpoint) must consult this object on
|
||||||
|
* every request, not cache it.
|
||||||
|
*/
|
||||||
|
private val infoHolder: InfoHolder,
|
||||||
|
private val store: IEventStore? = null,
|
||||||
|
) {
|
||||||
|
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
|
||||||
|
interface InfoHolder {
|
||||||
|
fun get(): RelayInfo
|
||||||
|
|
||||||
|
fun set(info: RelayInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
val supportedMethods: List<String> =
|
||||||
|
listOf(
|
||||||
|
Nip86Method.SUPPORTED_METHODS,
|
||||||
|
Nip86Method.BAN_PUBKEY,
|
||||||
|
Nip86Method.UNBAN_PUBKEY,
|
||||||
|
Nip86Method.LIST_BANNED_PUBKEYS,
|
||||||
|
Nip86Method.ALLOW_PUBKEY,
|
||||||
|
Nip86Method.UNALLOW_PUBKEY,
|
||||||
|
Nip86Method.LIST_ALLOWED_PUBKEYS,
|
||||||
|
Nip86Method.BAN_EVENT,
|
||||||
|
Nip86Method.ALLOW_EVENT,
|
||||||
|
Nip86Method.LIST_BANNED_EVENTS,
|
||||||
|
Nip86Method.ALLOW_KIND,
|
||||||
|
Nip86Method.DISALLOW_KIND,
|
||||||
|
Nip86Method.LIST_ALLOWED_KINDS,
|
||||||
|
Nip86Method.CHANGE_RELAY_NAME,
|
||||||
|
Nip86Method.CHANGE_RELAY_DESCRIPTION,
|
||||||
|
Nip86Method.CHANGE_RELAY_ICON,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a single RPC request. Synchronous-looking but does
|
||||||
|
* suspend internally for the `banevent` event-store delete path.
|
||||||
|
*/
|
||||||
|
suspend fun dispatch(req: Nip86Request): Nip86Response =
|
||||||
|
runCatching {
|
||||||
|
when (req.method) {
|
||||||
|
Nip86Method.SUPPORTED_METHODS -> {
|
||||||
|
result(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.BAN_PUBKEY -> {
|
||||||
|
val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]")
|
||||||
|
banStore.banPubkey(pk, reason)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.UNBAN_PUBKEY -> {
|
||||||
|
val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]")
|
||||||
|
banStore.unbanPubkey(pk)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.LIST_BANNED_PUBKEYS -> {
|
||||||
|
result(
|
||||||
|
banStore
|
||||||
|
.listBannedPubkeys()
|
||||||
|
.map { (pk, r) -> BannedPubkey(pk, r) }
|
||||||
|
.toJsonArray(BannedPubkey.serializer()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.ALLOW_PUBKEY -> {
|
||||||
|
val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]")
|
||||||
|
banStore.allowPubkey(pk, reason)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.UNALLOW_PUBKEY -> {
|
||||||
|
val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]")
|
||||||
|
banStore.unallowPubkey(pk)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.LIST_ALLOWED_PUBKEYS -> {
|
||||||
|
result(
|
||||||
|
banStore
|
||||||
|
.listAllowedPubkeys()
|
||||||
|
.map { (pk, r) -> AllowedPubkey(pk, r) }
|
||||||
|
.toJsonArray(AllowedPubkey.serializer()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.BAN_EVENT -> {
|
||||||
|
val (id, reason) = req.params.stringPair() ?: return malformed("expected [event_id, reason?]")
|
||||||
|
banStore.banEvent(id, reason)
|
||||||
|
// Also remove the event from the store if it's there.
|
||||||
|
store?.delete(Filter(ids = listOf(id)))
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.ALLOW_EVENT -> {
|
||||||
|
val (id, _) = req.params.stringPair() ?: return malformed("expected [event_id]")
|
||||||
|
banStore.allowEvent(id)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.LIST_BANNED_EVENTS -> {
|
||||||
|
result(
|
||||||
|
banStore
|
||||||
|
.listBannedEvents()
|
||||||
|
.map { (id, r) -> BannedEvent(id, r) }
|
||||||
|
.toJsonArray(BannedEvent.serializer()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.ALLOW_KIND -> {
|
||||||
|
val k = req.params.firstInt() ?: return malformed("expected [kind]")
|
||||||
|
banStore.allowKind(k)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.DISALLOW_KIND -> {
|
||||||
|
val k = req.params.firstInt() ?: return malformed("expected [kind]")
|
||||||
|
banStore.disallowKind(k)
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.LIST_ALLOWED_KINDS -> {
|
||||||
|
result(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.CHANGE_RELAY_NAME -> {
|
||||||
|
val name = req.params.firstString() ?: return malformed("expected [name]")
|
||||||
|
rewriteInfo { it.copy(name = name) }
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.CHANGE_RELAY_DESCRIPTION -> {
|
||||||
|
val desc = req.params.firstString() ?: return malformed("expected [description]")
|
||||||
|
rewriteInfo { it.copy(description = desc) }
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
Nip86Method.CHANGE_RELAY_ICON -> {
|
||||||
|
val icon = req.params.firstString() ?: return malformed("expected [icon_url]")
|
||||||
|
rewriteInfo { it.copy(icon = icon) }
|
||||||
|
result(JsonPrimitive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Nip86Response(error = "method not supported: ${req.method}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse { e ->
|
||||||
|
Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||||
|
val current = infoHolder.get().document
|
||||||
|
infoHolder.set(RelayInfo(transform(current)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [Nip11RelayInformation] is not a `data class`; do a manual field-by-field copy. */
|
||||||
|
private fun Nip11RelayInformation.copy(
|
||||||
|
name: String? = this.name,
|
||||||
|
description: String? = this.description,
|
||||||
|
icon: String? = this.icon,
|
||||||
|
) = Nip11RelayInformation(
|
||||||
|
id = this.id,
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
icon = icon,
|
||||||
|
pubkey = this.pubkey,
|
||||||
|
self = this.self,
|
||||||
|
contact = this.contact,
|
||||||
|
supported_nips = this.supported_nips,
|
||||||
|
supported_nip_extensions = this.supported_nip_extensions,
|
||||||
|
software = this.software,
|
||||||
|
version = this.version,
|
||||||
|
limitation = this.limitation,
|
||||||
|
relay_countries = this.relay_countries,
|
||||||
|
language_tags = this.language_tags,
|
||||||
|
tags = this.tags,
|
||||||
|
posting_policy = this.posting_policy,
|
||||||
|
privacy_policy = this.privacy_policy,
|
||||||
|
terms_of_service = this.terms_of_service,
|
||||||
|
payments_url = this.payments_url,
|
||||||
|
retention = this.retention,
|
||||||
|
fees = this.fees,
|
||||||
|
nip50 = this.nip50,
|
||||||
|
supported_grasps = this.supported_grasps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason")
|
||||||
|
|
||||||
|
private fun result(j: JsonElement) = Nip86Response(result = j, error = null)
|
||||||
|
|
||||||
|
private val rpcJson = Json { encodeDefaults = false }
|
||||||
|
|
||||||
|
private fun <T> List<T>.toJsonArray(serializer: KSerializer<T>): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this)
|
||||||
|
|
||||||
|
private fun JsonArray.stringPair(): Pair<String, String?>? {
|
||||||
|
val first = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() ?: return null
|
||||||
|
val second = (getOrNull(1) as? JsonPrimitive)?.contentOrNull()
|
||||||
|
return first to second
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull()
|
||||||
|
|
||||||
|
private fun JsonArray.firstInt(): Int? =
|
||||||
|
runCatching {
|
||||||
|
(this[0] as? JsonPrimitive)?.int
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||||
|
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a NIP-98 `Authorization: Nostr <base64-event>` header.
|
||||||
|
*
|
||||||
|
* NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies)
|
||||||
|
* `payload` tags. The relay must check:
|
||||||
|
* 1. Header is `Nostr <base64>`.
|
||||||
|
* 2. Decoded body is a kind-27235 event with a valid Schnorr signature.
|
||||||
|
* 3. The event's `created_at` is within ±60 s of now (NIP-98 spec).
|
||||||
|
* 4. The `method` tag matches the HTTP method.
|
||||||
|
* 5. The `u` tag matches the requested URL.
|
||||||
|
* 6. If a body is present, the `payload` tag matches `sha256(body)` hex.
|
||||||
|
*
|
||||||
|
* Returns the verified pubkey on success; `null` on any failure (the
|
||||||
|
* caller turns this into a `401 Unauthorized`).
|
||||||
|
*/
|
||||||
|
class Nip98AuthVerifier(
|
||||||
|
private val now: () -> Long = { TimeUtils.now() },
|
||||||
|
/** Allowed clock skew in seconds. NIP-98 says 60. */
|
||||||
|
private val toleranceSeconds: Long = 60,
|
||||||
|
) {
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun verify(
|
||||||
|
authorizationHeader: String?,
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
body: ByteArray?,
|
||||||
|
): Result {
|
||||||
|
if (authorizationHeader.isNullOrBlank()) return Result.Missing
|
||||||
|
if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME <base64>' header")
|
||||||
|
|
||||||
|
val token = authorizationHeader.substring(SCHEME.length).trim()
|
||||||
|
val json =
|
||||||
|
try {
|
||||||
|
Base64.decode(token).decodeToString()
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
return Result.Malformed("token is not valid base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
val event =
|
||||||
|
try {
|
||||||
|
OptimizedJsonMapper.fromJson(json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return Result.Malformed("token does not decode to a Nostr event")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind != HTTPAuthorizationEvent.KIND) {
|
||||||
|
return Result.Malformed("event kind ${event.kind} != ${HTTPAuthorizationEvent.KIND}")
|
||||||
|
}
|
||||||
|
if (!event.verify()) return Result.Malformed("bad event signature or id")
|
||||||
|
|
||||||
|
val skew = abs(event.createdAt - now())
|
||||||
|
if (skew > toleranceSeconds) {
|
||||||
|
return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-wrap as the typed event so the tag accessors work.
|
||||||
|
val auth =
|
||||||
|
HTTPAuthorizationEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!auth.method().equals(method, ignoreCase = true)) {
|
||||||
|
return Result.Malformed("method mismatch: expected $method, got ${auth.method()}")
|
||||||
|
}
|
||||||
|
if (auth.url() != url) {
|
||||||
|
return Result.Malformed("url mismatch: expected $url, got ${auth.url()}")
|
||||||
|
}
|
||||||
|
if (body != null && body.isNotEmpty()) {
|
||||||
|
val expected = sha256(body).toHexKey()
|
||||||
|
if (auth.payloadHash() != expected) {
|
||||||
|
return Result.Malformed("payload hash mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Verified(event.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Result {
|
||||||
|
data class Verified(
|
||||||
|
val pubkey: HexKey,
|
||||||
|
) : Result
|
||||||
|
|
||||||
|
object Missing : Result
|
||||||
|
|
||||||
|
data class Malformed(
|
||||||
|
val reason: String,
|
||||||
|
) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SCHEME = "Nostr "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ data class RelayConfig(
|
|||||||
val options: OptionsSection = OptionsSection(),
|
val options: OptionsSection = OptionsSection(),
|
||||||
val limits: LimitsSection = LimitsSection(),
|
val limits: LimitsSection = LimitsSection(),
|
||||||
val authorization: AuthorizationSection = AuthorizationSection(),
|
val authorization: AuthorizationSection = AuthorizationSection(),
|
||||||
|
val admin: AdminSection = AdminSection(),
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
|
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
|
||||||
@@ -133,6 +134,17 @@ data class RelayConfig(
|
|||||||
val kind_blacklist: List<Int> = emptyList(),
|
val kind_blacklist: List<Int> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-86 relay management API. When [pubkeys] is non-empty,
|
||||||
|
* `LocalRelayServer` exposes a POST endpoint at the relay path
|
||||||
|
* that accepts JSON-RPC admin requests authenticated via NIP-98
|
||||||
|
* HTTP-Auth. Only requests signed by one of these pubkeys are
|
||||||
|
* dispatched.
|
||||||
|
*/
|
||||||
|
data class AdminSection(
|
||||||
|
val pubkeys: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val mapper = tomlMapper { }
|
private val mapper = tomlMapper { }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BanStoreTest {
|
||||||
|
@Test
|
||||||
|
fun pubkeyBanIsCaseInsensitive() {
|
||||||
|
val s = BanStore()
|
||||||
|
s.banPubkey("ABCDEF1234".padEnd(64, '0'), "spam")
|
||||||
|
assertTrue(s.isBanned("abcdef1234".padEnd(64, '0')))
|
||||||
|
s.unbanPubkey("abcdef1234".padEnd(64, '0'))
|
||||||
|
assertFalse(s.isBanned("ABCDEF1234".padEnd(64, '0')))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowListEmptyMeansEveryoneAllowed() {
|
||||||
|
val s = BanStore()
|
||||||
|
assertFalse(s.hasAllowList())
|
||||||
|
// No allow list → policy decision is purely deny-based; the
|
||||||
|
// store doesn't say a pubkey IS allowed unless it's listed.
|
||||||
|
assertFalse(s.isAllowedPubkey("aaaa".padEnd(64, '0')))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowListNonEmptyTracksMembers() {
|
||||||
|
val s = BanStore()
|
||||||
|
s.allowPubkey("aa".padEnd(64, '0'), "trusted")
|
||||||
|
assertTrue(s.hasAllowList())
|
||||||
|
assertTrue(s.isAllowedPubkey("aa".padEnd(64, '0')))
|
||||||
|
assertFalse(s.isAllowedPubkey("bb".padEnd(64, '0')))
|
||||||
|
s.unallowPubkey("aa".padEnd(64, '0'))
|
||||||
|
assertFalse(s.hasAllowList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eventBanRoundTrip() {
|
||||||
|
val s = BanStore()
|
||||||
|
s.banEvent("ee".padEnd(64, '0'), "policy")
|
||||||
|
assertTrue(s.isBannedEvent("EE".padEnd(64, '0')))
|
||||||
|
s.allowEvent("ee".padEnd(64, '0'))
|
||||||
|
assertFalse(s.isBannedEvent("ee".padEnd(64, '0')))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindAllowDenyRules() {
|
||||||
|
val s = BanStore()
|
||||||
|
// Empty allow + empty deny → every kind is allowed.
|
||||||
|
assertTrue(s.isKindAllowed(1))
|
||||||
|
|
||||||
|
s.allowKind(1)
|
||||||
|
s.allowKind(7)
|
||||||
|
// Allow non-empty → only listed kinds are allowed.
|
||||||
|
assertTrue(s.isKindAllowed(1))
|
||||||
|
assertFalse(s.isKindAllowed(4))
|
||||||
|
|
||||||
|
s.disallowKind(7)
|
||||||
|
// Disallowing a kind removes it from the allow list and blocks.
|
||||||
|
assertFalse(s.isKindAllowed(7))
|
||||||
|
assertTrue(s.isKindAllowed(1))
|
||||||
|
assertEquals(listOf(1), s.listAllowedKinds())
|
||||||
|
assertEquals(listOf(7), s.listDisallowedKinds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsReflectStateForAuditTrail() {
|
||||||
|
val s = BanStore()
|
||||||
|
s.banPubkey("aa".padEnd(64, '0'), "spam")
|
||||||
|
s.banPubkey("bb".padEnd(64, '0'), null)
|
||||||
|
val banned = s.listBannedPubkeys().toMap()
|
||||||
|
assertEquals("spam", banned["aa".padEnd(64, '0')])
|
||||||
|
assertEquals(null, banned["bb".padEnd(64, '0')])
|
||||||
|
}
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||||
|
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||||
|
import com.vitorpamplona.quartz.relay.LocalRelayServer
|
||||||
|
import com.vitorpamplona.quartz.relay.Relay
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives a real `LocalRelayServer` over HTTP and proves the NIP-86
|
||||||
|
* admin RPC flow works end-to-end: NIP-98 auth, admin allow-list
|
||||||
|
* gate, ban mutation, and the resulting policy effect on a follow-up
|
||||||
|
* EVENT publish.
|
||||||
|
*/
|
||||||
|
class Nip86EndToEndTest {
|
||||||
|
private lateinit var relay: Relay
|
||||||
|
private lateinit var server: LocalRelayServer
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
private lateinit var nostrClient: NostrClient
|
||||||
|
|
||||||
|
private val httpClient = OkHttpClient.Builder().build()
|
||||||
|
|
||||||
|
private val admin = NostrSignerSync(KeyPair())
|
||||||
|
private val outsider = NostrSignerSync(KeyPair())
|
||||||
|
private val targetUser = NostrSignerSync(KeyPair())
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||||
|
relay = Relay(url = placeholder)
|
||||||
|
server =
|
||||||
|
LocalRelayServer(
|
||||||
|
relay = relay,
|
||||||
|
host = "127.0.0.1",
|
||||||
|
port = 0,
|
||||||
|
adminPubkeys = setOf(admin.pubKey),
|
||||||
|
).start()
|
||||||
|
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||||
|
nostrClient = NostrClient(builder, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun teardown() {
|
||||||
|
nostrClient.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val httpUrl get() = server.url.replace("ws://", "http://")
|
||||||
|
|
||||||
|
/** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */
|
||||||
|
private fun rpc(
|
||||||
|
request: Nip86Request,
|
||||||
|
signer: NostrSignerSync,
|
||||||
|
): okhttp3.Response {
|
||||||
|
val body = JsonMapper.toJson(request).encodeToByteArray()
|
||||||
|
val authTemplate =
|
||||||
|
HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body)
|
||||||
|
val authToken = signer.sign(authTemplate).toAuthToken()
|
||||||
|
return httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
|
||||||
|
.header("Authorization", authToken)
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supportedMethodsListsTheServersMethods() {
|
||||||
|
rpc(Nip86Request.supportedMethods(), admin).use {
|
||||||
|
assertEquals(200, it.code)
|
||||||
|
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
|
||||||
|
val arr = json.result as JsonArray
|
||||||
|
val names = arr.map { e -> e.jsonPrimitive.content }
|
||||||
|
assertTrue(names.contains("supportedmethods"))
|
||||||
|
assertTrue(names.contains("banpubkey"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun foreignSignerReturns403() {
|
||||||
|
rpc(Nip86Request.supportedMethods(), outsider).use {
|
||||||
|
assertEquals(403, it.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingAuthHeaderReturns401() {
|
||||||
|
val body = JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
|
||||||
|
httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
.use {
|
||||||
|
assertEquals(401, it.code)
|
||||||
|
assertTrue(it.headers["WWW-Authenticate"]?.startsWith("Nostr") == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun banPubkeyBlocksSubsequentEventsFromThatAuthor() =
|
||||||
|
runBlocking {
|
||||||
|
val relayUrl = server.url.normalizeRelayUrl()
|
||||||
|
|
||||||
|
// Baseline: targetUser can publish.
|
||||||
|
val before = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("first")), setOf(relayUrl))
|
||||||
|
assertEquals(true, before)
|
||||||
|
|
||||||
|
// Admin bans them.
|
||||||
|
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
|
||||||
|
assertEquals(200, it.code)
|
||||||
|
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
|
||||||
|
assertEquals(true, (resp.result as JsonPrimitive).boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent EVENT from the banned author is rejected.
|
||||||
|
val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl))
|
||||||
|
assertEquals(false, after, "DynamicBanPolicy must reject events from banned pubkeys")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changeRelayNameFlowsToNip11Endpoint() {
|
||||||
|
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
|
||||||
|
assertEquals(200, it.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the NIP-11 endpoint and confirm the new name is live.
|
||||||
|
val response =
|
||||||
|
httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(httpUrl)
|
||||||
|
.header("Accept", "application/nostr+json")
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
response.use {
|
||||||
|
val info = Nip11RelayInformation.fromJson(it.body.string())
|
||||||
|
assertEquals("renamed-by-admin", info.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun adminEndpointDisabledWhenNoPubkeysConfigured() =
|
||||||
|
runBlocking {
|
||||||
|
// Spin up a *separate* server with no admin pubkeys.
|
||||||
|
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||||
|
val openRelay = Relay(url = placeholder)
|
||||||
|
val openServer =
|
||||||
|
LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start()
|
||||||
|
try {
|
||||||
|
val openHttpUrl = openServer.url.replace("ws://", "http://")
|
||||||
|
val body =
|
||||||
|
JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
|
||||||
|
val authToken =
|
||||||
|
admin
|
||||||
|
.sign(
|
||||||
|
HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body),
|
||||||
|
).toAuthToken()
|
||||||
|
httpClient
|
||||||
|
.newCall(
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(openHttpUrl)
|
||||||
|
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
|
||||||
|
.header("Authorization", authToken)
|
||||||
|
.build(),
|
||||||
|
).execute()
|
||||||
|
.use {
|
||||||
|
assertEquals(403, it.code)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500)
|
||||||
|
openRelay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
|
||||||
|
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||||
|
import com.vitorpamplona.quartz.relay.RelayInfo
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class Nip86ServerTest {
|
||||||
|
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
|
||||||
|
val store = BanStore()
|
||||||
|
val holder =
|
||||||
|
Holder(RelayInfo(Nip11RelayInformation(name = "before", description = "before-desc")))
|
||||||
|
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
|
||||||
|
return Triple(server, store, holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Holder(
|
||||||
|
var current: RelayInfo,
|
||||||
|
) : Nip86Server.InfoHolder {
|
||||||
|
override fun get() = current
|
||||||
|
|
||||||
|
override fun set(info: RelayInfo) {
|
||||||
|
current = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val pk = "a".repeat(64)
|
||||||
|
private val pk2 = "b".repeat(64)
|
||||||
|
private val eventId = "c".repeat(64)
|
||||||
|
private val relayUrl = RelayUrlNormalizer.normalize("ws://test/")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supportedMethodsRoundTrip() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, _, _) = fixture()
|
||||||
|
val resp = server.dispatch(Nip86Request.supportedMethods())
|
||||||
|
assertNull(resp.error)
|
||||||
|
val arr = resp.result as JsonArray
|
||||||
|
val names = arr.map { it.jsonPrimitive.content }
|
||||||
|
assertTrue(Nip86Method.SUPPORTED_METHODS in names)
|
||||||
|
assertTrue(Nip86Method.BAN_PUBKEY in names)
|
||||||
|
assertTrue(Nip86Method.CHANGE_RELAY_NAME in names)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, banStore, _) = fixture()
|
||||||
|
|
||||||
|
val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam"))
|
||||||
|
assertEquals(true, (ok.result as JsonPrimitive).boolean)
|
||||||
|
assertTrue(banStore.isBanned(pk))
|
||||||
|
|
||||||
|
val list = server.dispatch(Nip86Request.listBannedPubkeys())
|
||||||
|
val parsed =
|
||||||
|
kotlinx.serialization.json.Json
|
||||||
|
.decodeFromJsonElement(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()),
|
||||||
|
list.result as JsonArray,
|
||||||
|
)
|
||||||
|
assertEquals(1, parsed.size)
|
||||||
|
assertEquals(pk, parsed[0].pubkey)
|
||||||
|
assertEquals("spam", parsed[0].reason)
|
||||||
|
|
||||||
|
server.dispatch(Nip86Request.unbanPubkey(pk))
|
||||||
|
assertTrue(banStore.listBannedPubkeys().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowPubkeyAndListRoundTrip() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, banStore, _) = fixture()
|
||||||
|
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
|
||||||
|
server.dispatch(Nip86Request.allowPubkey(pk2))
|
||||||
|
assertTrue(banStore.hasAllowList())
|
||||||
|
|
||||||
|
val resp = server.dispatch(Nip86Request.listAllowedPubkeys())
|
||||||
|
val list =
|
||||||
|
kotlinx.serialization.json.Json
|
||||||
|
.decodeFromJsonElement(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()),
|
||||||
|
resp.result as JsonArray,
|
||||||
|
)
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, banStore, _) = fixture()
|
||||||
|
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
|
||||||
|
assertTrue(banStore.isBannedEvent(eventId))
|
||||||
|
|
||||||
|
val resp = server.dispatch(Nip86Request.listBannedEvents())
|
||||||
|
val list =
|
||||||
|
kotlinx.serialization.json.Json
|
||||||
|
.decodeFromJsonElement(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()),
|
||||||
|
resp.result as JsonArray,
|
||||||
|
)
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
assertEquals(eventId, list[0].id)
|
||||||
|
assertEquals("off-topic", list[0].reason)
|
||||||
|
|
||||||
|
// allowevent (which is "unban") removes the entry.
|
||||||
|
server.dispatch(Nip86Request.allowEvent(eventId))
|
||||||
|
assertTrue(banStore.listBannedEvents().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowKindAndDisallowKind() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, banStore, _) = fixture()
|
||||||
|
server.dispatch(Nip86Request.allowKind(1))
|
||||||
|
server.dispatch(Nip86Request.allowKind(7))
|
||||||
|
server.dispatch(Nip86Request.disallowKind(4))
|
||||||
|
|
||||||
|
val list = server.dispatch(Nip86Request.listAllowedKinds())
|
||||||
|
val ints = (list.result as JsonArray).map { it.jsonPrimitive.int }
|
||||||
|
assertEquals(listOf(1, 7), ints)
|
||||||
|
|
||||||
|
assertTrue(banStore.isKindAllowed(1))
|
||||||
|
assertTrue(banStore.isKindAllowed(7))
|
||||||
|
assertEquals(false, banStore.isKindAllowed(4))
|
||||||
|
assertEquals(false, banStore.isKindAllowed(99))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changeRelayNameDescriptionIconRewriteInfoDoc() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, _, holder) = fixture()
|
||||||
|
assertEquals("before", holder.current.document.name)
|
||||||
|
|
||||||
|
server.dispatch(Nip86Request.changeRelayName("after"))
|
||||||
|
assertEquals("after", holder.current.document.name)
|
||||||
|
|
||||||
|
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
|
||||||
|
assertEquals("nice relay", holder.current.document.description)
|
||||||
|
|
||||||
|
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
|
||||||
|
assertEquals("https://x/icon.png", holder.current.document.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unsupportedMethodReturnsError() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, _, _) = fixture()
|
||||||
|
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
|
||||||
|
assertNotNull(resp.error)
|
||||||
|
assertTrue(resp.error!!.contains("frobnicate"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingParamsAreReportedAsErrors() =
|
||||||
|
runBlocking {
|
||||||
|
val (server, _, _) = fixture()
|
||||||
|
// banpubkey requires at least one positional param.
|
||||||
|
val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY))
|
||||||
|
assertNotNull(resp.error)
|
||||||
|
assertTrue(resp.error!!.startsWith("invalid params"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay.admin
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
|
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class Nip98AuthVerifierTest {
|
||||||
|
private val verifier = Nip98AuthVerifier(now = { 1_000L })
|
||||||
|
|
||||||
|
private fun signedToken(
|
||||||
|
url: String,
|
||||||
|
method: String,
|
||||||
|
body: ByteArray? = null,
|
||||||
|
signer: NostrSignerSync = NostrSignerSync(KeyPair()),
|
||||||
|
createdAt: Long = 1_000L,
|
||||||
|
): Pair<String, String> {
|
||||||
|
val template = HTTPAuthorizationEvent.build(url = url, method = method, file = body, createdAt = createdAt)
|
||||||
|
val signed = signer.sign(template)
|
||||||
|
return signer.pubKey to signed.toAuthToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifiesAValidPostWithBody() =
|
||||||
|
runBlocking {
|
||||||
|
val body = "hello".encodeToByteArray()
|
||||||
|
val (pubkey, header) = signedToken("http://x/", "POST", body)
|
||||||
|
val r = verifier.verify(header, "POST", "http://x/", body)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Verified>(r)
|
||||||
|
assertEquals(pubkey, r.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingHeaderReturnsMissing() {
|
||||||
|
val r = verifier.verify(null, "POST", "http://x/", null)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Missing>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongSchemeIsMalformed() {
|
||||||
|
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||||
|
assertTrue(r.reason.contains("Nostr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun urlMismatchIsMalformed() {
|
||||||
|
val (_, header) = signedToken("http://x/", "POST")
|
||||||
|
val r = verifier.verify(header, "POST", "http://y/", null)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||||
|
assertTrue(r.reason.contains("url mismatch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun methodMismatchIsMalformed() {
|
||||||
|
val (_, header) = signedToken("http://x/", "POST")
|
||||||
|
val r = verifier.verify(header, "GET", "http://x/", null)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||||
|
assertTrue(r.reason.contains("method mismatch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun payloadHashMismatchIsMalformed() {
|
||||||
|
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
|
||||||
|
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||||
|
assertTrue(r.reason.contains("payload hash"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleCreatedAtIsMalformed() {
|
||||||
|
// Verifier's clock is fixed at 1_000; sign a token created 5
|
||||||
|
// minutes earlier — outside the 60s tolerance.
|
||||||
|
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
|
||||||
|
val r = verifier.verify(header, "POST", "http://x/", null)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||||
|
assertTrue(r.reason.contains("created_at"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonAuthEventKindIsMalformed() {
|
||||||
|
// Build a kind-1 event by hand and shove it into the header — it
|
||||||
|
// must be rejected because NIP-98 specifically uses kind 27235.
|
||||||
|
val signer = NostrSignerSync(KeyPair())
|
||||||
|
val template =
|
||||||
|
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
.build("not an auth event")
|
||||||
|
val signed = signer.sign(template)
|
||||||
|
val token =
|
||||||
|
"Nostr " +
|
||||||
|
kotlin.io.encoding.Base64
|
||||||
|
.encode(signed.toJson().encodeToByteArray())
|
||||||
|
val r = verifier.verify(token, "POST", "http://x/", null)
|
||||||
|
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||||
|
assertTrue(r.reason.contains("kind"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user