fix(relay): make the per-connection auth set a copy-on-write snapshot

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
This commit is contained in:
Claude
2026-08-01 16:58:36 +00:00
parent 2e90cc24ba
commit f2e7a07a97
2 changed files with 17 additions and 3 deletions
@@ -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<HexKey>()
@Volatile
private var authenticatedUsers = setOf<HexKey>()
/**
* 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, ""))
}
@@ -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<HexKey>
}