diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 533d7c7c67..1db7d59e8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -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 (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 (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, + ): List { + 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() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index f8e9e6b510..3c983f7085 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -84,6 +84,16 @@ class ConcordCommunitySession( /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = 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 = 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 = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index e68b6bc171..8a6fcb2065 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -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 = 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 { + val out = ArrayList() + 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) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt index 044dccde7a..58b0c55253 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt @@ -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() }) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt index ee4df00fe2..47ac84c4ce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -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 = 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 = LruCache(200) // Avoids sending multiple replies for each auth. - private val uniqueAuthChallengesSent: LruCache = LruCache(10) + private val uniqueAuthChallengesSent: LruCache = 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