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 {