From f7afc56752cb7b69168a2f9a9fa846aeaffdd12b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 19:30:19 +0000 Subject: [PATCH] feat(quartz): add optional NIP-42 AUTH relay policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce OptionalAuthPolicy, a relay-server policy that runs the full NIP-42 challenge/verify handshake — emitting the AUTH challenge on connect and recording verified pubkeys into the connection scope — but never requires it: EVENT, REQ, and COUNT are always accepted, so clients that ignore the challenge keep working. It subclasses FullAuthPolicy and only relaxes the EVENT/REQ/COUNT gates, so the authorize() hook and per-connection authenticatedUsers set behave identically; downstream policies can still gate or rewrite on caller identity. Wire it into geode via an optional_auth config option and a --optional-auth CLI flag (ignored when require_auth/--auth is set, since mandatory AUTH already sends the challenge). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BJWy4dBBYLbqthhLvbcZh7 --- geode/config.example.toml | 6 ++ .../kotlin/com/vitorpamplona/geode/Main.kt | 14 ++- .../geode/config/StaticConfig.kt | 7 ++ .../server/policies/OptionalAuthPolicy.kt | 57 +++++++++++ .../relay/server/NostrServerAuthTest.kt | 95 +++++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt diff --git a/geode/config.example.toml b/geode/config.example.toml index a95b625d59..7c83311b7b 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -62,6 +62,12 @@ verify_signatures = true # Require clients to NIP-42 AUTH before REQ/EVENT/COUNT. require_auth = false +# Advertise NIP-42 AUTH without requiring it: the relay sends the +# challenge and records clients that authenticate (so downstream +# policies can gate on identity), but REQ/EVENT/COUNT still work for +# clients that never AUTH. Ignored when require_auth = true. +# optional_auth = false + # Reject events whose `created_at` is more than this many seconds in # the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index e2becaf114..e0cd7f815e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -24,10 +24,12 @@ import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy @@ -64,6 +66,8 @@ import java.io.File * --info NIP-11 doc file (overrides [info] section) * --db sqlite db path (overrides [database].file) * --auth require NIP-42 AUTH (sets options.require_auth = true) + * --optional-auth advertise NIP-42 AUTH but don't require it (sets + * options.optional_auth = true; ignored when --auth is set) * --no-verify DO NOT verify event signatures (off by default * verify is on; use only for trusted-input * scenarios like fixture replay). @@ -84,6 +88,9 @@ fun main(args: Array) { val cliInfoFile = a.opt("--info")?.let { File(it) } val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } val requireAuth = a.flag("--auth") || config.options.require_auth + // Optional AUTH advertises the challenge without gating commands on it. + // Mandatory AUTH already sends the challenge, so it wins when both are set. + val optionalAuth = !requireAuth && (a.flag("--optional-auth") || config.options.optional_auth) // Verify is on by default; only disable when the operator explicitly // opts out (CLI `--no-verify` or `[options].verify_signatures = false` // in the config). @@ -109,7 +116,7 @@ fun main(args: Array) { val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) val policyBuilder: () -> IRelayPolicy = { - composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify) + composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify) } // Static [authorization] feeds the runtime BanStore only when no @@ -182,8 +189,9 @@ fun main(args: Array) { */ private fun composePolicy( config: StaticConfig, - advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + advertisedUrl: NormalizedRelayUrl, requireAuth: Boolean, + optionalAuth: Boolean, verifySigs: Boolean, parallelVerify: Boolean, ): IRelayPolicy { @@ -191,6 +199,8 @@ private fun composePolicy( if (requireAuth) { pieces += FullAuthPolicy(advertisedUrl) + } else if (optionalAuth) { + pieces += OptionalAuthPolicy(advertisedUrl) } config.options.reject_future_seconds?.let { secs -> diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 06f9120311..9b59d9f45e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -107,6 +107,13 @@ data class StaticConfig( data class OptionsSection( val reject_future_seconds: Int? = null, val require_auth: Boolean = false, + /** + * Advertise NIP-42 AUTH without requiring it: the relay sends the + * challenge and records clients that authenticate, but EVENT/REQ/COUNT + * still work for clients that never do. Ignored when [require_auth] is + * true (mandatory AUTH already sends the challenge). + */ + val optional_auth: Boolean = false, /** * Defaults to `true`: any relay accepting real traffic should * verify Schnorr signatures, and verifying-by-default closes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt new file mode 100644 index 0000000000..dc8124a90c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt @@ -0,0 +1,57 @@ +/* + * 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.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 + +/** + * Runs the full NIP-42 challenge/verify handshake but never *requires* it. + * + * Like [FullAuthPolicy], [onConnect] emits the AUTH challenge and [accept] + * (AuthCmd) validates a returned event (expiration, freshness, challenge match, + * relay match), recording verified pubkeys into the engine-owned connection + * scope. Unlike [FullAuthPolicy], EVENT, REQ, and COUNT are **always accepted** — + * a client that ignores the challenge and never authenticates keeps working. + * + * Use this when you want the relay to *advertise* authentication (so clients that + * do support NIP-42 can identify themselves, and downstream policies can gate or + * rewrite on the caller's identity via [authenticatedUsers]) without locking out + * clients that don't. It is the middle ground between [EmptyPolicy] (no challenge + * at all) and [FullAuthPolicy] (challenge required for every command). + * + * Because it subclasses [FullAuthPolicy], the [authorize] hook and the + * per-connection [authenticatedUsers] set behave identically; only the gating on + * EVENT/REQ/COUNT is relaxed. Subclasses can still tighten specific commands + * (e.g. require auth for kind 4 only) by overriding the relevant [accept] and + * reading [authenticatedUsers]. + */ +open class OptionalAuthPolicy( + relay: NormalizedRelayUrl, +) : FullAuthPolicy(relay) { + override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = PolicyResult.Accepted(cmd) +} 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 index 07d9cc0667..13f4f00046 100644 --- 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 @@ -32,6 +32,7 @@ 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.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.store.IEventStore @@ -464,6 +465,100 @@ class NostrServerAuthTest { server.close() } + // -- NIP-42: optional AUTH ------------------------------------------------- + + @Test + fun optionalAuthSendsChallengeOnConnect() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.close() + } + + @Test + fun optionalAuthAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // The challenge was sent, but a client that ignores it can still write. + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated()) + + // REQ works too — no CLOSED with auth-required. + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + assertTrue(collector.rawMessagesContaining("EOSE").isNotEmpty()) + assertTrue(collector.rawMessagesContaining("CLOSED").isEmpty()) + + server.close() + } + + @Test + fun optionalAuthStillRecordsAuthenticatedUsers() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // A client that DOES authenticate is still verified and recorded, so + // downstream policies can gate on identity. + session.receive(authJson(authEvent(challenge = msg.challenge))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertTrue((session.policy as OptionalAuthPolicy).isAuthenticated()) + assertTrue(session.requestContext.authenticatedUsers.contains(pubkey)) + + server.close() + } + + @Test + fun optionalAuthRejectsInvalidAuthButKeepsWorking() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // A bogus AUTH is rejected (challenge mismatch) but the connection is + // not authenticated and commands still flow. + session.receive(authJson(authEvent(challenge = "wrong-challenge"))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",false,")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated()) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains(",true,")) + + server.close() + } + // -- Custom AuthPolicy tests ----------------------------------------------- @Test