fix(quartz): NostrServer.ingest verifies inline when the queue hook is off

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
This commit is contained in:
Claude
2026-07-04 02:31:54 +00:00
parent 6ac2903a3a
commit 2be0c9f880
2 changed files with 52 additions and 10 deletions
@@ -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
@@ -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 {