From 2be0c9f8806a721c36b81a0f0901c79796e4e073 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:31:54 +0000 Subject: [PATCH] fix(quartz): NostrServer.ingest verifies inline when the queue hook is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingest() bypasses the per-connection policy chain, which is where VerifyPolicy lives. The IngestQueue verify hook only exists when parallelVerify is true, so with parallelVerify = false and skipVerify = false, ingest() previously verified nothing — an untrusted mirror upstream on a relay running the legacy in-policy verify path could inject forgeries. ingest() now verifies inline in that configuration (same rejection reason as the queue), so the documented "default keeps verify-everything semantics" holds regardless of parallelVerify. KDoc also spells out that ingest() skips the entire policy chain (blacklists, size limits), which callers must screen for themselves. Test covers the parallelVerify = false server: forged rejected, trusted skip and valid still land. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A --- .../nip01Core/relay/server/NostrServer.kt | 29 +++++++++++----- .../relay/server/NostrServerIngestTest.kt | 33 +++++++++++++++++-- 2 files changed, 52 insertions(+), 10 deletions(-) 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 81326a5bbf..9ec35db402 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 @@ -65,7 +65,7 @@ class NostrServer( private val store: IEventStore, policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, parentContext: CoroutineContext = SupervisorJob(), - parallelVerify: Boolean = false, + private val parallelVerify: Boolean = false, negentropySettings: NegentropySettings = NegentropySettings.Default, listener: RelayServerListener = RelayServerListener.None, limits: RelayLimits? = null, @@ -105,19 +105,32 @@ class NostrServer( * connection — e.g. a mirror worker streaming a trusted upstream * relay, or an import job. Routes through the same group-commit * [IngestQueue] and live fanout as a client EVENT publish, but skips - * the per-connection policy chain (there is no connection). + * the **entire** per-connection policy chain (there is no + * connection): no [VerifyPolicy], no allow/deny lists, no size + * limits. Callers own that screening — scope what may enter this + * path (e.g. geode's per-upstream mirror filters) accordingly. * - * [skipVerify] exempts the event from the parallel signature-verify - * hook — the relay-to-relay trust model: pass `true` only for events - * from an explicitly configured upstream that already verified them - * (Schnorr verify profiles at ~8% of busy ingest CPU). The default - * `false` keeps verify-everything semantics. + * [skipVerify] exempts the event from signature verification — the + * relay-to-relay trust model: pass `true` only for events from an + * explicitly configured upstream that already verified them (Schnorr + * verify profiles at ~8% of busy ingest CPU). The default `false` + * keeps verify-everything semantics regardless of configuration: + * when the [IngestQueue] hook is on ([parallelVerify]) it verifies + * there, otherwise this method verifies inline — without this, a + * server whose verification lives in the (bypassed) policy chain + * would silently ingest forgeries. */ suspend fun ingest( event: Event, skipVerify: Boolean = false, onComplete: (IEventStore.InsertOutcome) -> Unit, - ) = liveStore.submit(event, skipVerify, onComplete) + ) { + if (!skipVerify && !parallelVerify && !event.verify()) { + onComplete(IEventStore.InsertOutcome.Rejected("invalid: bad signature or id")) + return + } + liveStore.submit(event, skipVerify, onComplete) + } init { // Deferred-FTS catch-up worker: tokenizes in the gaps between diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerIngestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerIngestTest.kt index 085edb3098..f1a881432e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerIngestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerIngestTest.kt @@ -59,12 +59,15 @@ class NostrServerIngestTest { sig = "f".repeat(128), ) - private fun createServer(dispatcher: CoroutineDispatcher): NostrServer = + private fun createServer( + dispatcher: CoroutineDispatcher, + parallelVerify: Boolean = true, + ): NostrServer = NostrServer( store = EventStore(null), policyBuilder = { EmptyPolicy }, parentContext = dispatcher, - parallelVerify = true, + parallelVerify = parallelVerify, ) private suspend fun NostrServer.ingestOutcome( @@ -118,6 +121,32 @@ class NostrServerIngestTest { server.close() } + @Test + fun forgedEventIsRejectedEvenWithoutTheQueueVerifyHook() = + runTest { + // With parallelVerify = false the IngestQueue has no verify + // hook AND ingest() bypasses the policy chain where + // VerifyPolicy would live — the inline fallback is the only + // thing standing between an untrusted mirror and forgeries. + val server = createServer(UnconfinedTestDispatcher(testScheduler), parallelVerify = false) + + val outcome = server.ingestOutcome(forgedEvent(), skipVerify = false) + assertTrue(outcome is IEventStore.InsertOutcome.Rejected) + assertTrue(outcome.reason.contains("signature")) + + // The trust switch and valid events still work on this config. + assertEquals( + IEventStore.InsertOutcome.Accepted, + server.ingestOutcome(forgedEvent(2), skipVerify = true), + ) + assertEquals( + IEventStore.InsertOutcome.Accepted, + server.ingestOutcome(signedEvent(), skipVerify = false), + ) + + server.close() + } + @Test fun trustIsPerSubmissionNotPerQueue() = runTest {