From f2e7a07a97474b53aad3bea2e3e774319057adf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:58:36 +0000 Subject: [PATCH] fix(relay): make the per-connection auth set a copy-on-write snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit finding on the StoreQueryContext seam: RelaySession kept authenticatedUsers as a plain mutable LinkedHashSet and handed out a live view through RequestContext. A store (or EventSource) reading the set during a long REQ replay — exactly what StoreQueryContext invites — could race a concurrent NIP-42 AUTH commit on another coroutine: iteration vs. add on an unsynchronized set is a ConcurrentModificationException or a torn read. The engine now swaps an immutable Set behind a @Volatile field on each AUTH (the only writer), so every read is a consistent snapshot and holding one across a replay is safe. AUTH is rare; the copy is off the hot path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45 --- .../quartz/nip01Core/relay/server/RelaySession.kt | 15 +++++++++++++-- .../relay/server/backend/RequestContext.kt | 5 ++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 7aa1e208d6..630ddec74a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -82,8 +83,18 @@ class RelaySession( * The authenticated-identity store for this connection. The engine is the * only writer (committed in [handleAuth] on a successful NIP-42 AUTH); the * policy and the data plane read it through [requestContext]. + * + * Copy-on-write on purpose: readers run concurrently with the engine — + * a REQ replay coroutine can hold the set (via `StoreQueryContext` or an + * `EventSource` reading [RequestContext.authenticatedUsers]) while a + * later AUTH frame commits a new identity. Each read hands out the + * current **immutable** set, so an in-flight query keeps a consistent + * snapshot instead of racing a mutating `LinkedHashSet`; `@Volatile` + * makes the swapped reference visible across threads. AUTH is rare, so + * the copy costs nothing on the hot path. */ - private val authenticatedUsers = mutableSetOf() + @Volatile + private var authenticatedUsers = setOf() /** * The per-connection scope. Handed to the [policy] at connect (so gating @@ -261,7 +272,7 @@ class RelaySession( // Single, engine-side commit into the connection scope — after the full // chain approved and a verifying policy voted to record. - if (record) authenticatedUsers.add(cmd.event.pubKey) + if (record) authenticatedUsers = authenticatedUsers + cmd.event.pubKey send(OkMessage(cmd.event.id, true, "")) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt index b492e80017..7948ce61cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt @@ -63,7 +63,10 @@ interface RequestContext { * The pubkeys that have authenticated on this connection via NIP-42. Empty * when the connection is unauthenticated. Backed by the engine-owned scope * and read live, so a REQ that arrives after a successful AUTH sees the - * freshly recorded pubkey(s). + * freshly recorded pubkey(s). Each read returns an **immutable snapshot** + * (the engine swaps the set copy-on-write on AUTH), so holding one across + * a long replay is safe — it just won't grow if another AUTH lands + * mid-query. */ val authenticatedUsers: Set }