mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+60
-19
@@ -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()
|
||||
}
|
||||
|
||||
+10
@@ -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) }
|
||||
|
||||
|
||||
+20
@@ -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)
|
||||
|
||||
+29
@@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user