feat(concord): NIP-42 AUTH as the derived plane stream key (CORD-01 §4b)

Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them only
to a connection authenticated AS the plane's derived stream key — a Concord wrap
is authored by the stream key and p-tagged to a throwaway ephemeral key, so the
member is neither author nor recipient. Amethyst authenticated only as the user,
so every plane REQ was refused ('auth-required: all authors must be
authenticated') and no channel or message ever loaded, even though the community
list (kind 13302) folded correctly.

Amethyst's relay-auth plumbing already supports multiple identities per
connection: RelayAuthenticator sends one AuthCmd per event in the list its
provider returns, deduped on (pubkey, challenge). This wires the derived stream
keys into that provider:

- ConcordCommunitySession.streamKeys() exposes control + folded-channel GroupKeys.
- ConcordSessionManager.streamAuthSecretsFor(relay) returns the stream secret keys
  a NIP-42 challenge from that relay must be answered with (relay-scoped to each
  community's own relays — the same scope the plane subscription uses).
- AuthCoordinator signs one kind-22242 per stream key locally (raw KeyPair via
  NostrSignerSync — never the account signer, never exposing user identity), and
  attaches them to every challenge independent of the user-auth policy. On auth
  success the existing syncFilters re-fires the previously-refused plane REQ.
- RelayAuthStatus LruCaches widened 10 -> 200 since one connection now
  authenticates as the user plus many plane keys (control + channels).

Mirrors Armada's streamAuth.ts (sign one AUTH per scoped stream key, accumulating
on the connection). Verified live on-device by the debug pass: authing as the
derived stream key returns the control-plane wraps that were previously refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-12 01:21:58 +00:00
parent 90998ee838
commit 60475c10c0
5 changed files with 125 additions and 22 deletions
@@ -24,9 +24,13 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -60,6 +64,13 @@ class AuthCoordinator(
client,
scope,
signWithAllLoggedInUsers = { relayUrl, authTemplate ->
// Concord plane traffic is gated behind NIP-42 as the derived *stream key*, not the
// user: a relay serves a plane's kind-1059 wraps only to a connection authenticated
// as that stream key. These AUTHs expose no user identity (ephemeral derived keys)
// and are signed locally, so we always attach them — independent of the user-auth
// policy below — or Concord channels/messages never load. No-op for non-Concord relays.
val streamAuths = signConcordStreamAuths(relayUrl, authTemplate)
// Reconstruct *why* this relay wants auth from what we're doing with it, so each
// account's ledger can apply follow-based trust and (later) explain the prompt.
// Built lazily so the no-ledgers auto-allow path below doesn't pay for it.
@@ -86,33 +97,63 @@ class AuthCoordinator(
}
val shouldAuth = outcome.shouldAuth
if (shouldAuth) {
// Remember why we granted this relay so the settings screen can explain it.
currentLedgers.firstOrNull()?.recordGrant(context)
val userAuths =
if (shouldAuth) {
// Remember why we granted this relay so the settings screen can explain it.
currentLedgers.firstOrNull()?.recordGrant(context)
// distinct() returns Set<Account> (the key type U of ListWithUniqueSetCache)
val results =
authWithAccounts.distinct().mapNotNull {
if (it.signer.isWriteable()) {
try {
it.signer.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
// distinct() returns Set<Account> (the key type U of ListWithUniqueSetCache)
val results =
authWithAccounts.distinct().mapNotNull {
if (it.signer.isWriteable()) {
try {
it.signer.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
null
}
} else {
null
}
} else {
null
}
}
// Always auth, even with random keys
if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate))
} else {
emptyList()
}
// Always auth, even with random keys (unless we're only here for stream auth).
if (results.isNotEmpty()) {
results
} else if (streamAuths.isEmpty()) {
listOf(tempAccount.sign(authTemplate))
} else {
emptyList()
}
} else {
emptyList()
}
streamAuths + userAuths
},
)
/**
* Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every
* watched account. Signed locally from the derived stream secret (a raw [KeyPair] via
* [NostrSignerSync]) — never the account signer, and never surfacing the user's identity.
*/
private suspend fun signConcordStreamAuths(
relayUrl: NormalizedRelayUrl,
authTemplate: EventTemplate<RelayAuthEvent>,
): List<RelayAuthEvent> {
val secrets = authWithAccounts.distinct().flatMap { it.concordSessions.streamAuthSecretsFor(relayUrl) }
if (secrets.isEmpty()) return emptyList()
return secrets.mapNotNull { secret ->
try {
NostrSignerSync(KeyPair(privKey = secret)).sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e)
null
}
}
}
fun destroy() {
receiver.destroy()
}
@@ -84,6 +84,16 @@ class ConcordCommunitySession(
/** The current Chat Plane addresses to subscribe to, one per folded channel. */
fun channelAddresses(): Set<HexKey> = lock.withLock { channelKeysByAddress.keys.toSet() }
/**
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
* one per folded channel. These are the identities a NIP-42 relay must see the
* connection authenticate as (kind 22242) to serve the wraps — a Concord wrap is
* authored by the stream key and `p`-tagged to a throwaway ephemeral key, so the
* member is neither author nor recipient and the relay refuses unless we AUTH as the
* stream key itself.
*/
fun streamKeys(): List<GroupKey> = lock.withLock { listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second } }
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) }
@@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -103,6 +105,24 @@ class ConcordSessionManager(
/** The `authors` set (control + known channel planes) for the kind-1059 subscription. */
fun subscribeAddresses(): Set<HexKey> = registry.subscribeAddresses()
/**
* The stream secret keys that must answer a NIP-42 AUTH challenge from [relay]:
* every plane (control + folded channels) of every joined community whose relays
* include [relay]. Concord relays serve a plane's kind-1059 wraps only to a
* connection authenticated as that stream key, so the relay-auth layer signs a
* kind-22242 with each of these (locally, never the user's signer) — without them
* the connection is authed only as the user, the plane REQ is refused, and no
* channel or message ever loads.
*/
fun streamAuthSecretsFor(relay: NormalizedRelayUrl): List<ByteArray> {
val out = ArrayList<ByteArray>()
for (session in registry.sessions()) {
val relays = session.entry.relays.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relay in relays) session.streamKeys().forEach { out.add(it.secretKey) }
}
return out
}
/** Route an inbound stream wrap; true if it was a Concord plane wrap we applied. */
fun ingest(wrap: Event): Boolean {
val applied = registry.ingest(wrap)
@@ -26,11 +26,13 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ConcordSessionManagerTest {
@@ -97,4 +99,31 @@ class ConcordSessionManagerTest {
?.name,
)
}
@Test
fun exposesStreamKeysScopedToTheCommunityRelaysForNip42Auth() =
runTest {
val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example"))
val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha")))
val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope)
testScheduler.runCurrent()
val hosted = RelayUrlNormalizer.normalize("wss://r.example")
val elsewhere = RelayUrlNormalizer.normalize("wss://other.example")
// Before any fold, only the control-plane key must AUTH — and only on the community's relay.
val beforeFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() }
assertTrue(beforeFold.contains(alpha.controlPlane.secretKey.toHexKey()))
assertTrue(manager.streamAuthSecretsFor(elsewhere).isEmpty()) // relay-scoped
// After the Control Plane folds, the #general channel key joins the AUTH set.
alpha.genesisWraps.forEach { manager.ingest(it) }
testScheduler.runCurrent()
val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch)
val afterFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() }
assertTrue(afterFold.contains(alpha.controlPlane.secretKey.toHexKey()))
assertTrue(afterFold.contains(general.secretKey.toHexKey()))
assertFalse(manager.streamAuthSecretsFor(elsewhere).any { it.toHexKey() == general.secretKey.toHexKey() })
}
}
@@ -28,11 +28,14 @@ import kotlin.concurrent.Volatile
class RelayAuthStatus {
// Keeps track of auth responses to update the relay with all filters
// after the authentication happen
private val authResponseWatcher: LruCache<HexKey, AuthEventReceiptStatus> = LruCache(10)
// after the authentication happen.
// Sized generously: one connection may authenticate as many identities at once — the
// user plus every Concord plane stream key hosted on that relay (control + channels) —
// and if older entries roll off, OK-tracking / hasFinishedAllAuths() accounting degrades.
private val authResponseWatcher: LruCache<HexKey, AuthEventReceiptStatus> = LruCache(200)
// Avoids sending multiple replies for each auth.
private val uniqueAuthChallengesSent: LruCache<ChallengePair, ChallengePair> = LruCache(10)
private val uniqueAuthChallengesSent: LruCache<ChallengePair, ChallengePair> = LruCache(200)
// Latest epoch-second at which a tracked AUTH event received a successful OK.
// Read by RelayAuthSnapshot consumers for staleness checks (e.g. proactive