feat(quartz): add optional NIP-42 AUTH relay policy

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJWy4dBBYLbqthhLvbcZh7
This commit is contained in:
Claude
2026-07-01 19:30:19 +00:00
parent d0497eaed7
commit f7afc56752
5 changed files with 177 additions and 2 deletions
+6
View File
@@ -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
@@ -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 <file> NIP-11 doc file (overrides [info] section)
* --db <file> 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<String>) {
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<String>) {
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<String>) {
*/
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 ->
@@ -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
@@ -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<EventCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = PolicyResult.Accepted(cmd)
}
@@ -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