From cfead47d71bb7e601e22926563c7b79ceac7e6a2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 21 Mar 2026 13:21:21 -0400 Subject: [PATCH] Improves design of the policy --- .../kotlinSerialization/CommandKSerializer.kt | 3 +- .../relay/commands/toRelay/AuthCmd.kt | 4 +- .../nip01Core/relay/server/AuthPolicy.kt | 169 ------ .../nip01Core/relay/server/IRelayPolicy.kt | 91 +++ .../nip01Core/relay/server/NostrServer.kt | 43 +- .../nip01Core/relay/server/RelaySession.kt | 174 ++---- .../relay/server/policies/EmptyPolicy.kt | 47 ++ .../relay/server/policies/FullAuthPolicy.kt | 104 ++++ .../relay/server/policies/PolicyStack.kt | 64 ++ .../relay/server/policies/VerifyPolicy.kt | 58 ++ .../vitorpamplona/quartz/utils/TimeUtils.kt | 6 + .../relay/server/NostrServerAuthTest.kt | 536 +++++++++++++++++ .../nip01Core/relay/server/NostrServerTest.kt | 548 ++---------------- .../commands/toRelay/CommandDeserializer.kt | 3 +- 14 files changed, 1011 insertions(+), 839 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/AuthPolicy.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt index 3d6cea8ca..4d3eba299 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -115,7 +116,7 @@ object CommandKSerializer : KSerializer { } AuthCmd.LABEL -> { - AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject)) + AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) } else -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt index 8d187b8e6..01d41b767 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/AuthCmd.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent class AuthCmd( - val event: Event, + val event: RelayAuthEvent, ) : Command { override fun label(): String = LABEL diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/AuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/AuthPolicy.kt deleted file mode 100644 index 67ccbb6db..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/AuthPolicy.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * 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.nip01Core.relay.server - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter - -/** - * Policy that controls authentication requirements for relay commands. - * - * Each trigger point receives the set of pubkeys that have authenticated on the - * current session, allowing the policy to make per-user decisions. Implementations - * can range from fully open (no auth) to fine-grained per-kind or per-user rules. - */ -interface AuthPolicy { - /** - * Evaluates whether an incoming EVENT command should be accepted. - * - * @param event The event the client wants to publish. - * @param authedPubkeys Pubkeys authenticated on this session (empty if none). - * @return [Accepted] to store the event, or [Rejected] with a reason string. - */ - fun acceptEvent( - event: Event, - authedPubkeys: Set, - ): PolicyResult - - /** - * Evaluates a REQ command, optionally rewriting the filter list. - * - * The policy may narrow filters to match what the authenticated user is - * allowed to see (e.g., restrict to their own DMs), or reject the - * subscription entirely. - * - * @param filters The filters from the REQ command. - * @param authedPubkeys Pubkeys authenticated on this session. - * @return [Accepted] with an optional replacement filter list, or [Rejected]. - */ - fun acceptReq( - filters: List, - authedPubkeys: Set, - ): ReqPolicyResult - - /** - * Evaluates a COUNT command. - * - * @param filters The filters from the COUNT command. - * @param authedPubkeys Pubkeys authenticated on this session. - * @return [Accepted] to allow counting, or [Rejected] with a reason. - */ - fun acceptCount( - filters: List, - authedPubkeys: Set, - ): PolicyResult - - /** - * Filters a live event before it is forwarded to a subscriber. - * - * Called for each event that matches a subscription's filters. Return - * true to deliver the event, false to suppress it for this session. - * - * @param event The event about to be sent. - * @param authedPubkeys Pubkeys authenticated on this session. - */ - fun canSendToSession( - event: Event, - authedPubkeys: Set, - ): Boolean = true -} - -sealed interface PolicyResult { - data object Accepted : PolicyResult - - data class Rejected( - val reason: String, - ) : PolicyResult -} - -sealed interface ReqPolicyResult { - data class Accepted( - val filters: List? = null, - ) : ReqPolicyResult - - data class Rejected( - val reason: String, - ) : ReqPolicyResult -} - -/** - * Allows all commands without authentication. This is the default policy. - */ -class OpenPolicy : AuthPolicy { - override fun acceptEvent( - event: Event, - authedPubkeys: Set, - ) = PolicyResult.Accepted - - override fun acceptReq( - filters: List, - authedPubkeys: Set, - ) = ReqPolicyResult.Accepted() - - override fun acceptCount( - filters: List, - authedPubkeys: Set, - ) = PolicyResult.Accepted - - override fun canSendToSession( - event: Event, - authedPubkeys: Set, - ) = true -} - -/** - * Requires authentication for all EVENT, REQ, and COUNT commands. - * Replicates the previous `requireAuth = true` behavior. - */ -class RequireAuthPolicy : AuthPolicy { - override fun acceptEvent( - event: Event, - authedPubkeys: Set, - ) = if (authedPubkeys.isNotEmpty()) { - PolicyResult.Accepted - } else { - PolicyResult.Rejected("auth-required: this relay requires authentication") - } - - override fun acceptReq( - filters: List, - authedPubkeys: Set, - ) = if (authedPubkeys.isNotEmpty()) { - ReqPolicyResult.Accepted() - } else { - ReqPolicyResult.Rejected("auth-required: this relay requires authentication") - } - - override fun acceptCount( - filters: List, - authedPubkeys: Set, - ) = if (authedPubkeys.isNotEmpty()) { - PolicyResult.Accepted - } else { - PolicyResult.Rejected("auth-required: this relay requires authentication") - } - - override fun canSendToSession( - event: Event, - authedPubkeys: Set, - ) = authedPubkeys.isNotEmpty() -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt new file mode 100644 index 000000000..9a65df954 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -0,0 +1,91 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Defines custom behavior for this relay. + */ +interface IRelayPolicy { + fun onConnect(send: (Message) -> Unit) + + /** + * Evaluates whether an incoming EVENT command should be accepted. + * + * @param cmd The event the client wants to publish. + * @return [PolicyResult.Accepted] to store the event, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: EventCmd): PolicyResult + + /** + * Evaluates a REQ command, optionally rewriting the filter list. + * + * @param cmd The filters from the REQ command. + * @return [PolicyResult.Accepted] with an optional replacement filter list, or [PolicyResult.Rejected]. + */ + fun accept(cmd: ReqCmd): PolicyResult + + /** + * Evaluates a COUNT command, optionally rewriting the filter list. + * + * @param cmd The filters from the COUNT command. + * @return [PolicyResult.Accepted] to allow counting, or [PolicyResult.Rejected] with a reason. + */ + fun accept(cmd: CountCmd): PolicyResult + + /** + * Evaluates whether an incoming AUTH command should be accepted. + * + * @param cmd The event the client wants to auth. + * @return [PolicyResult.Accepted] to log in, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: AuthCmd): PolicyResult + + /** + * Filters a live event before it is forwarded to a subscriber. + * + * Called for each event that matches a subscription's filters. Return + * true to deliver the event, false to suppress it for this session. + * + * @param event The event about to be sent. + */ + fun canSendToSession(event: Event): Boolean = true + + operator fun plus(other: IRelayPolicy) = listOf(this, other) +} + +sealed interface PolicyResult { + class Accepted( + val cmd: T, + ) : PolicyResult + + class Rejected( + val reason: String, + ) : PolicyResult +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index 519e2ca8f..d21d344c4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.server -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.crypto.verify -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope @@ -32,30 +30,25 @@ import kotlin.coroutines.CoroutineContext /** * This class manages per-connection subscriptions as coroutines. Each - * subscription ([REQ]) launches a child coroutine that first replays stored + * subscription (REQ) launches a child coroutine that first replays stored * events matching the filters, sends EOSE, and then streams live events. - * Closing a subscription ([CLOSE]) immediately cancels its coroutine. + * Closing a subscription (CLOSE) immediately cancels its coroutine. * * The server is transport-agnostic: callers feed incoming JSON via - * [processMessage] and receive outgoing JSON via the [send] callback + * [RelaySession.receive] and receive outgoing JSON via the [RelaySession.send] callback * provided to [connect]. This allows use with any WebSocket library. * - * @param store The [EventStore] backing this relay. - * @param relayUrl The URL of this relay, used for NIP-42 authentication. - * @param authPolicy Controls authentication requirements for relay commands. - * Defaults to [OpenPolicy] (no authentication required). - * @param verify Validates incoming events. Defaults to cryptographic - * verification (id + signature). Override for testing. + * @param store The [IEventStore] backing this relay. + * @param policyBuilder Controls requirements for relay commands. */ class NostrServer( private val store: IEventStore, - val relayUrl: NormalizedRelayUrl = NormalizedRelayUrl("wss://relay.example.com/"), - val authPolicy: AuthPolicy = OpenPolicy(), + private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, private val parentContext: CoroutineContext = SupervisorJob(), - private val verify: (Event) -> Boolean = { it.verify() }, ) { private val subStore = LiveEventStore(store) + /** Scope for all subscriptions. */ private val scope = CoroutineScope(parentContext + SupervisorJob()) /** Active client sessions keyed by an opaque connection id. */ @@ -69,26 +62,22 @@ class NostrServer( */ fun connect(send: (String) -> Unit) = RelaySession( - server = this, + policy = policyBuilder(), store = subStore, - verify = verify, scope = scope, onSend = send, - onClose = ::disconnect, - ).also { session -> connections.put(session.hashCode(), session) } - - /** - * Removes a client connection and cancels all its subscriptions. - */ - private fun disconnect(session: RelaySession) { - connections.remove(session.hashCode()) - } + onClose = { session -> + connections.remove(session.hashCode()) + }, + ).also { session -> + connections.put(session.hashCode(), session) + } /** * Shuts down the server, cancelling all subscriptions and sessions. */ fun shutdown() { - connections.forEach { id, session -> session.cancelAllSubscriptions() } + connections.forEach { _, session -> session.cancelAllSubscriptions() } connections.clear() scope.cancel() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 90f9f524f..8bf99765c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -20,10 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.server -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult @@ -37,10 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.RandomInstance -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -48,55 +42,22 @@ import kotlinx.coroutines.launch /** * Represents a single connected client with its active subscriptions. - * - * Supports NIP-42 authentication. Multiple pubkeys may authenticate on - * the same session. When [NostrServer.requireAuth] is true, EVENT, REQ - * and COUNT commands are rejected until at least one pubkey authenticates. */ class RelaySession( - private val server: NostrServer, private val store: LiveEventStore, - private val verify: (Event) -> Boolean, + val policy: IRelayPolicy, private val scope: CoroutineScope, private val onSend: (String) -> Unit, private val onClose: (RelaySession) -> Unit, ) : AutoCloseable { private val subscriptions = LargeCache() - /** The challenge string sent to this client for NIP-42 authentication. */ - val challenge: String = RandomInstance.randomChars(32) - - /** Set of pubkeys that have successfully authenticated on this session. */ - private val authenticatedUsers = mutableSetOf() - - /** Returns true if at least one pubkey has authenticated. */ - fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty() - - /** Returns the set of authenticated pubkeys for this session. */ - fun authenticatedPubkeys(): Set = authenticatedUsers.toSet() - - /** - * Sends the AUTH challenge to the client. - * Call this after the WebSocket connection is established. - */ - fun sendAuthChallenge() { - send(AuthMessage(challenge)) - } - - fun send(message: Message) { - try { - onSend(OptimizedJsonMapper.toJson(message)) - } catch (e: Exception) { - Log.w("ClientSession", "Failed to send to ${e.message}") - } - } - - fun addSubscription( + private fun addSubscription( subId: String, job: Job, ) = subscriptions.put(subId, job) - fun cancelSubscription(subId: String): Boolean = + private fun cancelSubscription(subId: String): Boolean = subscriptions.remove(subId)?.let { it.cancel() true @@ -107,6 +68,14 @@ class RelaySession( subscriptions.clear() } + fun send(message: Message) { + try { + onSend(OptimizedJsonMapper.toJson(message)) + } catch (e: Exception) { + Log.w("ClientSession", "Failed to send to ${e.message}") + } + } + override fun close() { cancelAllSubscriptions() onClose(this) @@ -117,10 +86,10 @@ class RelaySession( * * Parses the message as a NIP-01 command and dispatches it. */ - suspend fun processMessage(message: String) { + suspend fun receive(command: String) { val cmd = try { - OptimizedJsonMapper.fromJsonToCommand(message) + OptimizedJsonMapper.fromJsonToCommand(command) } catch (_: Exception) { send(NoticeMessage("error: could not parse message")) return @@ -143,105 +112,36 @@ class RelaySession( // -- NIP-42: AUTH --------------------------------------------------------- private fun handleAuth(cmd: AuthCmd) { - val event = cmd.event - - // Must be kind 22242 - if (event.kind != RelayAuthEvent.KIND) { - send(OkMessage(event.id, false, "invalid: wrong event kind")) - return - } - - // Verify signature and id - if (!verify(event)) { - send(OkMessage(event.id, false, "invalid: bad signature or id")) - return - } - - // created_at must be within 10 minutes - val now = TimeUtils.now() - val tenMinutes = 600L - if (event.createdAt < now - tenMinutes || event.createdAt > now + tenMinutes) { - send(OkMessage(event.id, false, "invalid: created_at is too far from the current time")) - return - } - - // Challenge tag must match - val eventChallenge = - event.tags.firstNotNullOfOrNull { tag -> - if (tag.size >= 2 && tag[0] == "challenge") tag[1] else null - } - if (eventChallenge != challenge) { - send(OkMessage(event.id, false, "invalid: challenge does not match")) - return - } - - // Relay tag must match this relay's URL - val eventRelay = - event.tags.firstNotNullOfOrNull { tag -> - if (tag.size >= 2 && tag[0] == "relay") tag[1] else null - } - if (eventRelay == null || !relayUrlMatches(eventRelay)) { - send(OkMessage(event.id, false, "invalid: relay url does not match")) - return - } - - // Authentication successful — add pubkey - authenticatedUsers.add(event.pubKey) - send(OkMessage(event.id, true, "")) - } - - private fun relayUrlMatches(eventRelayUrl: String): Boolean { - val ours = server.relayUrl.url.trimEnd('/') - val theirs = eventRelayUrl.trimEnd('/') - return ours.equals(theirs, ignoreCase = true) - } - - // -- NIP-01: EVENT -------------------------------------------------------- - private fun handleEvent(cmd: EventCmd) { - val event = cmd.event - - val result = server.authPolicy.acceptEvent(event, authenticatedPubkeys()) + val result = policy.accept(cmd) if (result is PolicyResult.Rejected) { - send(OkMessage(event.id, false, result.reason)) + send(OkMessage(cmd.event.id, false, result.reason)) return } - if (!verify(event)) { - send(OkMessage(event.id, false, "invalid: bad signature or id")) - return - } - - try { - store.insert(event) - send(OkMessage(event.id, true, "")) - } catch (e: Exception) { - send(OkMessage(event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) - } + send(OkMessage(cmd.event.id, true, "")) } // -- NIP-01: REQ ---------------------------------------------------------- private fun handleReq(cmd: ReqCmd) { - val result = server.authPolicy.acceptReq(cmd.filters, authenticatedPubkeys()) - if (result is ReqPolicyResult.Rejected) { + // Cancel any existing subscription with the same id (NIP-01 spec). + cancelSubscription(cmd.subId) + + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { send(ClosedMessage(cmd.subId, result.reason)) return } // Policy may rewrite filters to match the user's access level. - val filters = (result as ReqPolicyResult.Accepted).filters ?: cmd.filters + val filters = (result as PolicyResult.Accepted).cmd.filters - // Cancel any existing subscription with the same id (NIP-01 spec). - cancelSubscription(cmd.subId) - - val authed = authenticatedPubkeys() - val policy = server.authPolicy val job = scope.launch { try { store.query( filters = filters, onEach = { event -> - if (policy.canSendToSession(event, authed)) { + if (policy.canSendToSession(event)) { send(EventMessage(cmd.subId, event)) } }, @@ -263,15 +163,39 @@ class RelaySession( } } + // -- NIP-01: EVENT -------------------------------------------------------- + private fun handleEvent(cmd: EventCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + try { + store.insert(cmd.event) + send(OkMessage(cmd.event.id, true, "")) + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) + } + } + // -- NIP-45: COUNT -------------------------------------------------------- private fun handleCount(cmd: CountCmd) { - val result = server.authPolicy.acceptCount(cmd.filters, authenticatedPubkeys()) + val result = policy.accept(cmd) if (result is PolicyResult.Rejected) { send(ClosedMessage(cmd.queryId, result.reason)) return } - val total = store.count(cmd.filters) + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val total = store.count(filters) + send(CountMessage(cmd.queryId, CountResult(total))) } + + init { + policy.onConnect(::send) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt new file mode 100644 index 000000000..fe7429a43 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -0,0 +1,47 @@ +/* + * 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.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Allows all commands without authentication. This is the default policy. + */ +object EmptyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd) + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt new file mode 100644 index 000000000..525bfa144 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt @@ -0,0 +1,104 @@ +/* + * 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.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Requires authentication for all EVENT, REQ, and COUNT commands. + * Replicates the previous `requireAuth = true` behavior. + */ +open class FullAuthPolicy( + val relay: NormalizedRelayUrl, +) : IRelayPolicy { + /** The challenge string sent to this client for NIP-42 authentication. */ + val challenge: String = RandomInstance.randomChars(32) + + /** Set of pubkeys that have successfully authenticated on this session. */ + val authenticatedUsers = mutableSetOf() + + /** Returns true if at least one pubkey has authenticated. */ + fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty() + + override fun onConnect(send: (Message) -> Unit) { + send(AuthMessage(challenge)) + } + + override fun accept(cmd: AuthCmd): PolicyResult { + val event = cmd.event + + if (event.isExpired()) { + return PolicyResult.Rejected("invalid: auth event expired") + } + + if (!TimeUtils.withinTenMinutes(event.createdAt)) { + return PolicyResult.Rejected("invalid: created_at is too far from the current time") + } + + if (event.challenge() != challenge) { + return PolicyResult.Rejected("invalid: challenge does not match") + } + + if (event.relay() != relay) { + return PolicyResult.Rejected("invalid: relay url does not match") + } + + authenticatedUsers.add(event.pubKey) + + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: EventCmd): PolicyResult = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: ReqCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: CountCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt new file mode 100644 index 000000000..67b3b0f1c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt @@ -0,0 +1,64 @@ +/* + * 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.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +class PolicyStack( + vararg policies: IRelayPolicy, +) : IRelayPolicy { + val policies = policies.toList() + + override fun onConnect(send: (Message) -> Unit) { + policies.forEach { it.onConnect(send) } + } + + override fun accept(cmd: EventCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: ReqCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: CountCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: AuthCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + private inline fun runPolicies( + initialCmd: T, + operation: (IRelayPolicy, T) -> PolicyResult, + ): PolicyResult { + var currentCmd = initialCmd + for (policy in policies) { + val result = operation(policy, currentCmd) + if (result is PolicyResult.Rejected) return result + currentCmd = (result as PolicyResult.Accepted).cmd + } + return PolicyResult.Accepted(currentCmd) + } + + override fun canSendToSession(event: Event): Boolean = policies.all { it.canSendToSession(event) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt new file mode 100644 index 000000000..283fda670 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt @@ -0,0 +1,58 @@ +/* + * 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.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Allows all commands without authentication. This is the default policy. + */ +object VerifyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index c8fcbde7d..90ca1272c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -24,6 +24,7 @@ object TimeUtils { const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE + const val TEN_MINUTES = 10 * ONE_MINUTE const val FIFTEEN_MINUTES = 15 * ONE_MINUTE const val ONE_HOUR = 60 * ONE_MINUTE const val EIGHT_HOURS = 8 * ONE_HOUR @@ -70,4 +71,9 @@ object TimeUtils { fun ninetyDaysFromNow() = now() + NINETY_DAYS fun oneYearAgo() = now() - ONE_YEAR + + fun withinTenMinutes(time: Long): Boolean { + val now = now() + return time > now - TEN_MINUTES && time < now + TEN_MINUTES + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt new file mode 100644 index 000000000..bc89df39f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -0,0 +1,536 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerAuthTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val pubkey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String = hexId(1), + kind: Int = 1, + createdAt: Long = 1000L, + content: String = "hello", + tags: Array> = emptyArray(), + ) = Event(id, pubkey, createdAt, kind, tags, content, sig) + + /** + * Creates a server using the given dispatcher so coroutines run eagerly + * in tests (UnconfinedTestDispatcher). + */ + private fun createServer( + dispatcher: CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { FullAuthPolicy(relayUrl) }, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + private suspend fun RelaySession.insert(event: Event) { + val cmd = EventCmd(event) + this.receive(OptimizedJsonMapper.toJson(cmd)) + } + + /** Collects sent JSON messages for a connection. */ + private class MessageCollector { + val messages = mutableListOf() + + val sendCallback: (String) -> Unit = { messages.add(it) } + + /** + * Parses messages that can be round-tripped (EVENT, EOSE, NOTICE, + * CLOSED). OkMessage and CountMessage serialization uses formats + * incompatible with the client-side deserializer, so check those + * via [rawMessagesContaining]. + */ + fun parsedEventMessages() = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } + } + + /** + * Builds a kind 22242 auth event for testing. Because verify = { true }, + * the id and signature don't need to be real. + */ + private fun authEvent( + challenge: String, + relay: String = relayUrl.url, + pubKey: String = pubkey, + createdAt: Long = TimeUtils.now(), + ) = RelayAuthEvent( + id = hexId(99), + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("relay", relay), + arrayOf("challenge", challenge), + ), + content = "", + sig = sig, + ) + + private fun authJson(event: RelayAuthEvent): String { + val cmd = AuthCmd(event) + return OptimizedJsonMapper.toJson(cmd) + } + + private fun authJson(event: Event) = """["AUTH",${event.toJson()}]""" + + @Test + fun authChallengeIsSentOnRequest() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.shutdown() + } + + @Test + fun authSucceedsWithValidEvent() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) + assertTrue(session.policy.authenticatedUsers.contains(pubkey)) + + server.shutdown() + } + + @Test + fun authFailsWithWrongChallenge() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = authEvent(challenge = "wrong-challenge") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongRelay() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge, relay = "wss://wrong.relay.com/") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("relay url")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithExpiredTimestamp() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = + authEvent( + challenge = msg.challenge, + createdAt = TimeUtils.now() - 1200L, // 20 minutes ago + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("created_at")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongKind() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Create an event with wrong kind (1 instead of 22242) + val event = + Event( + id = hexId(99), + pubKey = pubkey, + createdAt = TimeUtils.now(), + kind = 1, + tags = + arrayOf( + arrayOf("relay", relayUrl.url), + arrayOf("challenge", msg.challenge), + ), + content = "", + sig = sig, + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("NOTICE") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("error")) + assertTrue(okMessages[0].contains("could not parse message")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun multipleUsersCanAuthenticate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // First user authenticates + val event1 = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(event1)) + + // Second user authenticates on the same session + val event2 = authEvent(challenge = msg.challenge, pubKey = pubkey2) + session.receive(authJson(event2)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[1].contains("\"true\"")) + + val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers + assertEquals(2, authedPubkeys.size) + assertTrue(authedPubkeys.contains(pubkey)) + assertTrue(authedPubkeys.contains(pubkey2)) + + server.shutdown() + } + + // -- NIP-42: requireAuth --------------------------------------------------- + + @Test + fun requireAuthRejectsEventWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsReqWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsCountWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["COUNT","q1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthAllowsCommandsAfterAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate first + val authEv = authEvent(challenge = msg.challenge) + session.receive(authJson(authEv)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + // Now EVENT should work + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains("\"true\"")) + + // REQ should work + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val eoseMessages = collector.rawMessagesContaining("EOSE") + assertTrue(eoseMessages.isNotEmpty()) + + server.shutdown() + } + + @Test + fun noAuthRequiredAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { EmptyPolicy }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // EVENT should work without auth when using OpenPolicy + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + server.shutdown() + } + + // -- Custom AuthPolicy tests ----------------------------------------------- + + @Test + fun customPolicyRejectsSpecificEventKinds() = + runTest { + // Policy that blocks kind 4 (DMs) from unauthenticated users. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = + if (cmd.event.kind == 4 && authenticatedUsers.isEmpty()) { + PolicyResult.Rejected("auth-required: kind 4 events require authentication") + } else { + PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { policy }) + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + // Kind 1 should be accepted without auth + val note = testEvent(hexId(1), kind = 1) + session.receive("""["EVENT",${note.toJson()}]""") + assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) + + // Kind 4 should be rejected without auth + val dm = testEvent(hexId(2), kind = 4) + session.receive("""["EVENT",${dm.toJson()}]""") + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun customPolicyRewritesFilters() = + runTest { + // Policy that restricts kind 4 queries to the authed user's own messages. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult { + val hasDmFilter = cmd.filters.any { it.kinds?.contains(4) == true } + if (!hasDmFilter) return PolicyResult.Accepted(cmd) + if (authenticatedUsers.isEmpty()) { + return PolicyResult.Rejected("auth-required: kind 4 requires auth") + } + // Rewrite: restrict to authed user's pubkey as author + val rewritten = + cmd.filters.map { filter -> + if (filter.kinds?.contains(4) == true) { + filter.copy(authors = authenticatedUsers.toList()) + } else { + filter + } + } + return PolicyResult.Accepted(ReqCmd(cmd.subId, rewritten)) + } + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(store = store, dispatcher = dispatcher, policyBuilder = { policy }) + + // Insert DMs from two different authors + store.insert(testEvent(hexId(1), kind = 4, createdAt = 100L)) // from pubkey + store.insert( + Event(hexId(2), pubkey2, 200L, 4, emptyArray(), "secret", sig), + ) // from pubkey2 + + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate as pubkey + val auth = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(auth)) + + // Query kind 4 — policy should rewrite to only return pubkey's events + session.receive("""["REQ","sub1",{"kinds":[4]}]""") + + val events = collector.parsedEventMessages().filterIsInstance() + assertEquals(1, events.size) + assertEquals(pubkey, events[0].event.pubKey) + + server.shutdown() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 6c7adee53..165bfb0e4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -21,31 +21,25 @@ package com.vitorpamplona.quartz.nip01Core.relay.server import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class NostrServerTest { private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" - private val pubkey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" - private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") private fun hexId(n: Int): String = n.toString().padStart(64, '0') @@ -62,21 +56,19 @@ class NostrServerTest { * in tests (UnconfinedTestDispatcher). */ private fun createServer( - store: IEventStore = EventStore(null), dispatcher: kotlinx.coroutines.CoroutineDispatcher, - authPolicy: AuthPolicy = OpenPolicy(), + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, ): NostrServer = NostrServer( store = store, - relayUrl = relayUrl, - authPolicy = authPolicy, + policyBuilder = policyBuilder, parentContext = dispatcher, - verify = { true }, ) private suspend fun RelaySession.insert(event: Event) { val cmd = EventCmd(event) - this.processMessage(OptimizedJsonMapper.toJson(cmd)) + this.receive(OptimizedJsonMapper.toJson(cmd)) } /** Collects sent JSON messages for a connection. */ @@ -99,31 +91,6 @@ class NostrServerTest { fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } } - /** - * Builds a kind 22242 auth event for testing. Because verify = { true }, - * the id and signature don't need to be real. - */ - private fun authEvent( - challenge: String, - relay: String = relayUrl.url, - pubKey: String = pubkey, - createdAt: Long = TimeUtils.now(), - ) = Event( - id = hexId(99), - pubKey = pubKey, - createdAt = createdAt, - kind = RelayAuthEvent.KIND, - tags = - arrayOf( - arrayOf("relay", relay), - arrayOf("challenge", challenge), - ), - content = "", - sig = sig, - ) - - private fun authJson(event: Event) = """["AUTH",${event.toJson()}]""" - // -- EVENT command --------------------------------------------------------- @Test @@ -131,14 +98,14 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) val collector = MessageCollector() val c1 = server.connect(collector.sendCallback) val event = testEvent() val eventJson = """["EVENT",${event.toJson()}]""" - c1.processMessage(eventJson) + c1.receive(eventJson) val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) @@ -156,15 +123,15 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) val collector = MessageCollector() val c1 = server.connect(collector.sendCallback) val event = testEvent() val eventJson = """["EVENT",${event.toJson()}]""" - c1.processMessage(eventJson) - c1.processMessage(eventJson) + c1.receive(eventJson) + c1.receive(eventJson) val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) @@ -181,7 +148,7 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) // Pre-populate store store.insert(testEvent(hexId(1), kind = 1, createdAt = 100L)) @@ -192,7 +159,7 @@ class NostrServerTest { val c1 = server.connect(collector.sendCallback) val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) val parsed = collector.parsedEventMessages() val events = parsed.filterIsInstance() @@ -213,7 +180,7 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) for (i in 1..10) { store.insert(testEvent(hexId(i), createdAt = i.toLong())) @@ -223,7 +190,7 @@ class NostrServerTest { val c1 = server.connect(collector.sendCallback) val reqJson = """["REQ","sub1",{"limit":3}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) val events = collector.parsedEventMessages().filterIsInstance() assertEquals(3, events.size) @@ -237,8 +204,8 @@ class NostrServerTest { fun liveSubscriptionReceivesNewEvents() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -247,7 +214,7 @@ class NostrServerTest { // Subscribe to kind 1 val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) // After REQ, we should have EOSE val countAfterEose = collector1.messages.size @@ -266,8 +233,8 @@ class NostrServerTest { fun liveSubscriptionFiltersNonMatchingEvents() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -275,7 +242,7 @@ class NostrServerTest { val c2 = server.connect(collector2.sendCallback) val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) val countAfterEose = collector1.messages.size @@ -293,8 +260,8 @@ class NostrServerTest { fun closeStopsSubscription() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -302,11 +269,11 @@ class NostrServerTest { val c2 = server.connect(collector2.sendCallback) val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) // Close the subscription val closeJson = """["CLOSE","sub1"]""" - c1.processMessage(closeJson) + c1.receive(closeJson) val countAfterClose = collector1.messages.size @@ -322,7 +289,7 @@ class NostrServerTest { fun replacingSubscriptionCancelsOld() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(EventStore(null), dispatcher) + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -330,10 +297,10 @@ class NostrServerTest { val c2 = server.connect(collector2.sendCallback) // First subscription for kind 1 - c1.processMessage("""["REQ","sub1",{"kinds":[1]}]""") + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") // Replace with kind 4 - c1.processMessage("""["REQ","sub1",{"kinds":[4]}]""") + c1.receive("""["REQ","sub1",{"kinds":[4]}]""") val countAfterReplace = collector1.messages.size @@ -361,7 +328,7 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) store.insert(testEvent(hexId(1), kind = 1)) store.insert(testEvent(hexId(2), kind = 1)) @@ -371,7 +338,7 @@ class NostrServerTest { val c1 = server.connect(collector.sendCallback) val countJson = """["COUNT","q1",{"kinds":[1]}]""" - c1.processMessage(countJson) + c1.receive(countJson) val countMessages = collector.rawMessagesContaining("COUNT") assertEquals(1, countMessages.size) @@ -386,16 +353,16 @@ class NostrServerTest { fun disconnectCancelsAllSubscriptions() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() val c1 = server.connect(collector1.sendCallback) val c2 = server.connect(collector2.sendCallback) - c1.processMessage("""["REQ","sub1",{"kinds":[1]}]""") - c1.processMessage("""["REQ","sub2",{"kinds":[4]}]""") + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") + c1.receive("""["REQ","sub2",{"kinds":[4]}]""") c1.close() @@ -420,458 +387,11 @@ class NostrServerTest { val collector = MessageCollector() val c1 = server.connect(collector.sendCallback) - c1.processMessage("not valid json") + c1.receive("not valid json") assertEquals(1, collector.messages.size) assertTrue(collector.messages[0].contains("NOTICE")) - server.shutdown() - } - - // -- NIP-42: AUTH ---------------------------------------------------------- - - @Test - fun authChallengeIsSentOnRequest() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - session.sendAuthChallenge() - - assertEquals(1, collector.messages.size) - assertTrue(collector.messages[0].contains("\"AUTH\"")) - assertTrue(collector.messages[0].contains(session.challenge)) - - server.shutdown() - } - - @Test - fun authSucceedsWithValidEvent() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - val event = authEvent(challenge = session.challenge) - session.processMessage(authJson(event)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(session.isAuthenticated()) - assertTrue(session.authenticatedPubkeys().contains(pubkey)) - - server.shutdown() - } - - @Test - fun authFailsWithWrongChallenge() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - val event = authEvent(challenge = "wrong-challenge") - session.processMessage(authJson(event)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) - assertTrue(okMessages[0].contains("challenge")) - assertFalse(session.isAuthenticated()) - - server.shutdown() - } - - @Test - fun authFailsWithWrongRelay() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - val event = authEvent(challenge = session.challenge, relay = "wss://wrong.relay.com/") - session.processMessage(authJson(event)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) - assertTrue(okMessages[0].contains("relay url")) - assertFalse(session.isAuthenticated()) - - server.shutdown() - } - - @Test - fun authFailsWithExpiredTimestamp() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - val event = - authEvent( - challenge = session.challenge, - createdAt = TimeUtils.now() - 1200L, // 20 minutes ago - ) - session.processMessage(authJson(event)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) - assertTrue(okMessages[0].contains("created_at")) - assertFalse(session.isAuthenticated()) - - server.shutdown() - } - - @Test - fun authFailsWithWrongKind() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - // Create an event with wrong kind (1 instead of 22242) - val event = - Event( - id = hexId(99), - pubKey = pubkey, - createdAt = TimeUtils.now(), - kind = 1, - tags = - arrayOf( - arrayOf("relay", relayUrl.url), - arrayOf("challenge", session.challenge), - ), - content = "", - sig = sig, - ) - session.processMessage(authJson(event)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) - assertTrue(okMessages[0].contains("wrong event kind")) - assertFalse(session.isAuthenticated()) - - server.shutdown() - } - - @Test - fun multipleUsersCanAuthenticate() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - // First user authenticates - val event1 = authEvent(challenge = session.challenge, pubKey = pubkey) - session.processMessage(authJson(event1)) - - // Second user authenticates on the same session - val event2 = authEvent(challenge = session.challenge, pubKey = pubkey2) - session.processMessage(authJson(event2)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"true\"")) - - val authedPubkeys = session.authenticatedPubkeys() - assertEquals(2, authedPubkeys.size) - assertTrue(authedPubkeys.contains(pubkey)) - assertTrue(authedPubkeys.contains(pubkey2)) - - server.shutdown() - } - - // -- NIP-42: requireAuth --------------------------------------------------- - - @Test - fun requireAuthRejectsEventWithoutAuth() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher, authPolicy = RequireAuthPolicy()) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - val event = testEvent() - session.processMessage("""["EVENT",${event.toJson()}]""") - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) - assertTrue(okMessages[0].contains("auth-required:")) - - server.shutdown() - } - - @Test - fun requireAuthRejectsReqWithoutAuth() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher, authPolicy = RequireAuthPolicy()) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - session.processMessage("""["REQ","sub1",{"kinds":[1]}]""") - - val closedMessages = collector.rawMessagesContaining("CLOSED") - assertEquals(1, closedMessages.size) - assertTrue(closedMessages[0].contains("auth-required:")) - - server.shutdown() - } - - @Test - fun requireAuthRejectsCountWithoutAuth() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher, authPolicy = RequireAuthPolicy()) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - session.processMessage("""["COUNT","q1",{"kinds":[1]}]""") - - val closedMessages = collector.rawMessagesContaining("CLOSED") - assertEquals(1, closedMessages.size) - assertTrue(closedMessages[0].contains("auth-required:")) - - server.shutdown() - } - - @Test - fun requireAuthAllowsCommandsAfterAuth() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store = store, dispatcher = dispatcher, authPolicy = RequireAuthPolicy()) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - // Authenticate first - val authEv = authEvent(challenge = session.challenge) - session.processMessage(authJson(authEv)) - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - - // Now EVENT should work - val event = testEvent() - session.processMessage("""["EVENT",${event.toJson()}]""") - - val allOk = collector.rawMessagesContaining("OK") - assertEquals(2, allOk.size) - assertTrue(allOk[1].contains("\"true\"")) - - // REQ should work - session.processMessage("""["REQ","sub1",{"kinds":[1]}]""") - - val eoseMessages = collector.rawMessagesContaining("EOSE") - assertTrue(eoseMessages.isNotEmpty()) - - server.shutdown() - } - - @Test - fun noAuthRequiredAllowsCommandsWithoutAuth() = - runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher) - val collector = MessageCollector() - - val session = server.connect(collector.sendCallback) - - // EVENT should work without auth when using OpenPolicy - val event = testEvent() - session.processMessage("""["EVENT",${event.toJson()}]""") - - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - - server.shutdown() - } - - // -- Custom AuthPolicy tests ----------------------------------------------- - - @Test - fun customPolicyRejectsSpecificEventKinds() = - runTest { - // Policy that blocks kind 4 (DMs) from unauthenticated users. - val policy = - object : AuthPolicy { - override fun acceptEvent( - event: Event, - authedPubkeys: Set, - ) = if (event.kind == 4 && authedPubkeys.isEmpty()) { - PolicyResult.Rejected("auth-required: kind 4 events require authentication") - } else { - PolicyResult.Accepted - } - - override fun acceptReq( - filters: List, - authedPubkeys: Set, - ) = ReqPolicyResult.Accepted() - - override fun acceptCount( - filters: List, - authedPubkeys: Set, - ) = PolicyResult.Accepted - } - - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(dispatcher = dispatcher, authPolicy = policy) - val collector = MessageCollector() - val session = server.connect(collector.sendCallback) - - // Kind 1 should be accepted without auth - val note = testEvent(hexId(1), kind = 1) - session.processMessage("""["EVENT",${note.toJson()}]""") - assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) - - // Kind 4 should be rejected without auth - val dm = testEvent(hexId(2), kind = 4) - session.processMessage("""["EVENT",${dm.toJson()}]""") - val okMessages = collector.rawMessagesContaining("OK") - assertEquals(2, okMessages.size) - assertTrue(okMessages[1].contains("\"false\"")) - assertTrue(okMessages[1].contains("auth-required:")) - - server.shutdown() - } - - @Test - fun customPolicyRewritesFilters() = - runTest { - // Policy that restricts kind 4 queries to the authed user's own messages. - val policy = - object : AuthPolicy { - override fun acceptEvent( - event: Event, - authedPubkeys: Set, - ) = PolicyResult.Accepted - - override fun acceptReq( - filters: List, - authedPubkeys: Set, - ): ReqPolicyResult { - val hasDmFilter = filters.any { it.kinds?.contains(4) == true } - if (!hasDmFilter) return ReqPolicyResult.Accepted() - if (authedPubkeys.isEmpty()) { - return ReqPolicyResult.Rejected("auth-required: kind 4 requires auth") - } - // Rewrite: restrict to authed user's pubkey as author - val rewritten = - filters.map { filter -> - if (filter.kinds?.contains(4) == true) { - filter.copy(authors = authedPubkeys.toList()) - } else { - filter - } - } - return ReqPolicyResult.Accepted(rewritten) - } - - override fun acceptCount( - filters: List, - authedPubkeys: Set, - ) = PolicyResult.Accepted - } - - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store = store, dispatcher = dispatcher, authPolicy = policy) - - // Insert DMs from two different authors - store.insert(testEvent(hexId(1), kind = 4, createdAt = 100L)) // from pubkey - store.insert( - Event(hexId(2), pubkey2, 200L, 4, emptyArray(), "secret", sig), - ) // from pubkey2 - - val collector = MessageCollector() - val session = server.connect(collector.sendCallback) - - // Authenticate as pubkey - val auth = authEvent(challenge = session.challenge, pubKey = pubkey) - session.processMessage(authJson(auth)) - - // Query kind 4 — policy should rewrite to only return pubkey's events - session.processMessage("""["REQ","sub1",{"kinds":[4]}]""") - - val events = collector.parsedEventMessages().filterIsInstance() - assertEquals(1, events.size) - assertEquals(pubkey, events[0].event.pubKey) - - server.shutdown() - } - - @Test - fun customPolicyFiltersLiveEvents() = - runTest { - // Policy that only delivers events to authenticated sessions - val policy = - object : AuthPolicy { - override fun acceptEvent( - event: Event, - authedPubkeys: Set, - ) = PolicyResult.Accepted - - override fun acceptReq( - filters: List, - authedPubkeys: Set, - ) = ReqPolicyResult.Accepted() - - override fun acceptCount( - filters: List, - authedPubkeys: Set, - ) = PolicyResult.Accepted - - override fun canSendToSession( - event: Event, - authedPubkeys: Set, - ) = authedPubkeys.isNotEmpty() - } - - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store = store, dispatcher = dispatcher, authPolicy = policy) - - // Unauthenticated subscriber - val unauthCollector = MessageCollector() - val unauthSession = server.connect(unauthCollector.sendCallback) - unauthSession.processMessage("""["REQ","sub1",{"kinds":[1]}]""") - val countAfterEose = unauthCollector.messages.size - - // Authenticated publisher - val pubCollector = MessageCollector() - val pubSession = server.connect(pubCollector.sendCallback) - - // Publish an event - pubSession.insert(testEvent(hexId(1), kind = 1)) - - // Unauthenticated session should NOT receive the live event - assertEquals(countAfterEose, unauthCollector.messages.size) - server.shutdown() } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt index 40f5cae03..f8265d069 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt @@ -28,6 +28,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent class CommandDeserializer : StdDeserializer(Command::class.java) { val eventDeserializer = EventDeserializer() @@ -92,7 +93,7 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { AuthCmd.LABEL -> { jp.nextToken() AuthCmd( - event = eventDeserializer.deserialize(jp, ctxt), + event = eventDeserializer.deserialize(jp, ctxt) as RelayAuthEvent, ) }