diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 82cc60df9a..1c79588135 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter @@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -58,13 +61,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent @@ -92,6 +98,7 @@ import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Request import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -247,6 +254,41 @@ class Context( startCap = System.getenv("AMY_RELAY_SUB_CAP")?.toIntOrNull()?.coerceIn(1, 100) ?: 16, ).also { client.addConnectionListener(it) } + /** + * Concord plane stream-key AUTH (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* — the member is neither the wrap's author + * (the stream key) nor its recipient (a throwaway ephemeral key), so an account + * AUTH is refused. `amy concord` verbs register their control + channel stream + * secrets here (scoped to the community's relays, the same scope the plane REQ + * uses) before draining, and [relayAuth] answers a challenge from one of those + * relays with one kind-22242 per stream key — signed locally from the raw derived + * key, never the account, so no user identity is exposed. + */ + private val concordStreamSecrets = ConcurrentHashMap>() + private val concordStreamSigners = ConcurrentHashMap() + + /** Registers raw 32-byte Concord stream [secrets] to answer NIP-42 challenges from [relays]. */ + fun registerConcordStreamKeys( + relays: Set, + secrets: List, + ) { + if (relays.isEmpty() || secrets.isEmpty()) return + val hexes = secrets.map { it.toHexKey() } + for (relay in relays) concordStreamSecrets.getOrPut(relay) { ConcurrentHashMap.newKeySet() }.addAll(hexes) + } + + /** Signs one kind-22242 per Concord stream key registered for [relay] (empty if none). */ + private fun signConcordStreamAuths( + relay: NormalizedRelayUrl, + template: EventTemplate, + ): List = + concordStreamSecrets[relay].orEmpty().mapNotNull { hex -> + runCatching { + concordStreamSigners.getOrPut(hex) { NostrSignerSync(KeyPair(privKey = hex.hexToByteArray())) }.sign(template) + }.getOrNull() + } + /** * NIP-42 responder: answers a relay's AUTH challenge by signing with the * account key, so auth-gated relays serve our reads instead of CLOSing the @@ -254,16 +296,20 @@ class Context( * Only a local key auto-signs — a remote bunker signer is skipped, since a * per-relay remote round-trip during a crawl would stall it (and signing an * auth event with any key still unlocks relays that just want *some* auth). + * Any Concord stream keys registered via [registerConcordStreamKeys] for the + * challenging relay are signed alongside the account AUTH. */ private val relayAuth: RelayAuthenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { _, template, _ -> - if (signer is NostrSignerInternal) { - runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } - } else { - emptyList() - } + signWithAllLoggedInUsers = { relay, template, _ -> + val accountAuth = + if (signer is NostrSignerInternal) { + runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } + } else { + emptyList() + } + accountAuth + signConcordStreamAuths(relay, template) }, ) @@ -585,12 +631,21 @@ class Context( * proven-dead relays from future routing instead of paying the full * [timeoutMs] on them again. Slow-but-connected relays are NOT reported — * only hard connect failures, so a temporarily-busy relay isn't discarded. + * + * With [pendingOnAuthRequired], a relay that refuses the REQ with an + * `auth-required` CLOSED is kept pending rather than treated as terminal: the + * NIP-42 responder answers the challenge and the client re-fires this same + * subscription (`syncFilters`), so the post-auth events are collected instead of + * returning empty. If auth never satisfies it, the relay simply falls through to + * the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served + * only to a connection authenticated as the derived stream key. */ suspend fun drain( filters: Map>, timeoutMs: Long = 8_000, diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, + pendingOnAuthRequired: Boolean = false, ): List> { if (filters.isEmpty()) return emptyList() val eventChannel = Channel>(UNLIMITED) @@ -623,6 +678,9 @@ class Context( relay: NormalizedRelayUrl, forFilters: List?, ) { + // Keep the relay pending on an auth-required refusal: the authenticator answers the + // challenge and re-fires this subscription, so the post-auth events still arrive. + if (pendingOnAuthRequired && MachineReadablePrefix.parse(message) == MachineReadablePrefix.AUTH_REQUIRED) return doneChannel.trySend(relay to "closed:$message") } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index b45b222a82..7c0577670b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -47,6 +47,9 @@ object ConcordChannelCommands { Output.emit( mapOf( "name" to state.metadata?.name, + "description" to state.metadata?.description, + "icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, + "banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, "channels" to state.channels.values.map { mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private) @@ -72,7 +75,10 @@ object ConcordChannelCommands { val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now()) - val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + val relays = ConcordCommands.relaysFor(ctx, sc) + // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. + ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) + val acked = ctx.publish(wrap, relays).filterValues { it }.keys Output.emit(mapOf("event_id" to wrap.id, "channel" to channelId, "published_to" to acked.map { it.url })) return 0 } @@ -92,7 +98,10 @@ object ConcordChannelCommands { ctx.prepare() val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) - val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }).map { it.second } + val relays = ConcordCommands.relaysFor(ctx, sc) + // The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates. + ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } val msgs = ConcordActions.channelMessages(wraps, channel, channelId, sc.rootEpoch).takeLast(limit) Output.emit( mapOf( @@ -111,7 +120,11 @@ object ConcordChannelCommands { sc: StoredCommunity, ): ConcordCommunityState { val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) - val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }).map { it.second } + val relays = ConcordCommands.relaysFor(ctx, sc) + // The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register + // it so the drain's AUTH challenge is answered as the control plane, not the account. + ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner) }