mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(cli): AUTH as the Concord plane stream key so amy can read planes
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 and every plane REQ
came back empty (no channels, no messages), even though the community folded
its name.
Mirror the app's per-plane AUTH (60475c10c0) in the CLI:
- Context.registerConcordStreamKeys(relays, secrets) records the derived
control/channel stream secrets, scoped to the community's relays.
- The RelayAuthenticator provider now signs one kind-22242 per registered
stream key alongside the account AUTH — locally, from the raw derived key
(NostrSignerSync), never the account, so no user identity is exposed.
- The concord channels/read/send verbs register their control + channel
stream keys before draining/publishing.
- drain() gains pendingOnAuthRequired: an auth-required CLOSED keeps the relay
pending instead of terminal, so the post-auth subscription re-fire delivers
the events rather than the one-shot drain returning empty. The concord verbs
opt in; all other drains are unchanged.
Verified end-to-end against the live Soapbox community (relay.ditto.pub /
relay.dreamith.to): `amy concord channels` now folds the name + 9 channels
consistently and `amy concord read` returns messages. `channels` also emits
the folded icon/banner/description pointers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a4d9087187
commit
122403baf4
@@ -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<NormalizedRelayUrl, MutableSet<HexKey>>()
|
||||
private val concordStreamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
|
||||
|
||||
/** Registers raw 32-byte Concord stream [secrets] to answer NIP-42 challenges from [relays]. */
|
||||
fun registerConcordStreamKeys(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
secrets: List<ByteArray>,
|
||||
) {
|
||||
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<RelayAuthEvent>,
|
||||
): List<RelayAuthEvent> =
|
||||
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<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 8_000,
|
||||
diagnoseSlow: Boolean = false,
|
||||
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
|
||||
pendingOnAuthRequired: Boolean = false,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
|
||||
@@ -623,6 +678,9 @@ class Context(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
// 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")
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user