mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
@@ -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
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
+95
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user