From f8416f4940f65694581093aecc1477e5ce082b29 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:07 -0400 Subject: [PATCH 1/3] feat(relay-auth): per-account NIP-42 override store + state on Account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them) lived in one process-wide DataStore keyed only by relay URL, so a DENY set for one account silently applied to every logged-in account. Move them to a per-account file under accounts// and warm-cache the overrides in memory on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered without a disk read. DataStoreRelayAuthPermissionStore now shares one DataStore per file path — DataStore v1 forbids two live instances on one file, and loadAccount can build the store more than once per account. Introduces the per-account state holders wired up by the following commits: Account.relayAuthPermissions, relayAuthLedger and relayNotifications. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 43 +++++++ .../model/accountsCache/AccountCacheState.kt | 6 + .../DataStoreRelayAuthPermissionStore.kt | 19 +++- .../model/InMemoryRelayAuthPermissionStore.kt | 68 ++++++++++++ .../model/RelayAuthPermissionCache.kt | 105 ++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 798aef0e4a..c374cc6d95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory @@ -127,6 +129,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor @@ -170,6 +176,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -339,6 +346,7 @@ class Account( val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, + relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), ) : IAccount { private var userProfileCache: User? = null @@ -353,6 +361,41 @@ class Account( val userMetadata = UserMetadataState(signer, cache, scope, settings) + // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is + // answered without a disk read. Backed by a per-account file (see AccountCacheState). + val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope) + + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), + // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH + // path (foreground screen + background notification consumer) shares one instance, and so an + // AUTH challenge is decided per account instead of folding every logged-in account together. + val relayAuthLedger = + RelayAuthPermissionLedger( + store = relayAuthPermissions, + globalPolicy = { settings.defaultRelayAuthPolicy.value }, + customToggles = { + RelayAuthCustomToggles( + myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value, + readFollows = settings.relayAuthTrustReadFollows.value, + messageFollows = settings.relayAuthTrustMessageFollows.value, + messageStrangers = settings.relayAuthTrustMessageStrangers.value, + ) + }, + isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false }, + isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false }, + isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors }, + isTrustedVenue = { venueId -> + venueId in publicChatList.flowSet.value || + venueId in communityList.flowSet.value || + Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true + }, + ) + + // Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming + // NOTIFY to the account whose AUTH the relay rejected and drops it here, so a prompt for one + // account never surfaces under another (the old cache was a process-wide singleton). + val relayNotifications = NotifyRequestsCache() + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 32a6183eec..7a07f95469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -224,6 +225,10 @@ class AccountCacheState( null } + // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one + // account never leaks into another (the store used to be a single app-wide file). + val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -247,6 +252,7 @@ class AccountCacheState( marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, powQueue = powQueue, + relayAuthPermissionStore = relayAuthPermissionStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt index abdb5c5e86..fe2e79fffd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.first import java.io.File import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap /** * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides @@ -45,11 +46,10 @@ class DataStoreRelayAuthPermissionStore( ) : RelayAuthPermissionStore { constructor(context: Context) : this(context.applicationContext.filesDir) - private val store: DataStore by lazy { - PreferenceDataStoreFactory.create( - produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, - ) - } + // DataStore v1 throws if two instances are ever active on the same file. loadAccount can build + // this store more than once for the same account (re-login, cache races), so the underlying + // DataStore is shared per absolute file path across the process instead of created per instance. + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb")) override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { val raw = store.data.first()[decisionKey(relayUrl)] ?: return null @@ -198,6 +198,15 @@ class DataStoreRelayAuthPermissionStore( private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") companion object { + // One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once + // per path, so concurrent constructions for the same account share a single active DataStore. + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" private const val RATIONALE_PREFIX = "rat:" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..eb4c514f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore + +/** + * Volatile, non-persistent [RelayAuthPermissionStore]. Used as the default for [com.vitorpamplona.amethyst.model.Account] + * instances built without a disk-backed store (Compose previews, unit tests, the mock account view + * models) so nothing on the auth path has to null-check the store. + */ +class InMemoryRelayAuthPermissionStore : RelayAuthPermissionStore { + private val decisions = mutableMapOf() + private val rationale = mutableMapOf>>() + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = decisions[relayUrl] + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + decisions[relayUrl] = decision + } + + override suspend fun clearDecision(relayUrl: String) { + decisions.remove(relayUrl) + } + + override suspend fun allDecisions(): Map = decisions.toMap() + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) { + val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() } + for ((kind, pubkeys) in additions) { + forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys) + } + } + + override suspend fun loadRationale(relayUrl: String): Map> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap() + + override suspend fun allRationales(): Map>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } } + + override suspend fun clearRationale(relayUrl: String) { + rationale.remove(relayUrl) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt new file mode 100644 index 0000000000..4f2ab00362 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * In-memory, warm-cached view over one account's [RelayAuthPermissionStore] (a per-account file — + * see [DataStoreRelayAuthPermissionStore] built from `accounts//`). Held on the + * [com.vitorpamplona.amethyst.model.Account] like the other state caches. + * + * The ALLOW/DENY overrides are the only thing read on the hot NIP-42 decision path + * ([RelayAuthPermissionLedger.decide]). They are snapshotted into memory once, right after the + * account loads, and served from there — so an incoming AUTH challenge is answered without a disk + * read (a slow disk hit here would stall login-time relay auth). Writes update memory *and* disk. + * + * Rationale + last-used are read only by the settings screen, off the hot path, so they pass + * straight through to disk. Implements [RelayAuthPermissionStore] so it drops into every existing + * call site (the ledger and the settings screen) unchanged. + */ +class RelayAuthPermissionCache( + private val disk: RelayAuthPermissionStore, + scope: CoroutineScope, +) : RelayAuthPermissionStore { + private val loaded = CompletableDeferred() + private val _overrides = MutableStateFlow>(emptyMap()) + + /** Per-relay overrides for this account, observable so the settings screen refreshes on change. */ + val overrides: StateFlow> = _overrides.asStateFlow() + + init { + scope.launch { + _overrides.value = disk.allDecisions() + loaded.complete(Unit) + } + } + + /** Non-suspending override lookup — returns null until the initial warm load finishes. */ + fun decisionOrNull(relayUrl: String): RelayAuthDecision? = _overrides.value[relayUrl] + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + loaded.await() + return _overrides.value[relayUrl] + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + disk.storeDecision(relayUrl, decision) + _overrides.update { it + (relayUrl to decision) } + } + + override suspend fun clearDecision(relayUrl: String) { + disk.clearDecision(relayUrl) + _overrides.update { it - relayUrl } + } + + override suspend fun allDecisions(): Map { + loaded.await() + return _overrides.value + } + + // --- rationale + last-used: settings-screen only, never on the auth decision path --- + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) = disk.recordUse(relayUrl, additions) + + override suspend fun loadRationale(relayUrl: String): Map> = disk.loadRationale(relayUrl) + + override suspend fun allRationales(): Map>> = disk.allRationales() + + override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl) + + override suspend fun allLastUsed(): Map = disk.allLastUsed() +} From 0ec63958b87f12a55077de1a53d7800c0718cd75 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:22 -0400 Subject: [PATCH 2/3] fix(relay-auth): only AUTH a relay with accounts that actually use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared NostrClient serves every logged-in account, so a relay's NIP-42 AUTH challenge is not tied to any one of them. AuthCoordinator used to fold every account's verdict into one decision and then sign with EVERY account (or a random throwaway key), so a paid relay like inbox.nostr.wine billed and de-anonymized accounts that never used it — including via a merged read filter that merely named them, and via account switches / the background notification consumer. Decide and sign PER ACCOUNT now: an account signs only if the relay is in its own relay list, or it is publishing its own event there (RelayAuthFirstParty), AND its own ledger verdict (Account.relayAuthLedger) allows it. A subscription merely naming the account is NOT first-party — that is the merged-filter false positive. The random-ephemeral-key fallback is removed. Own-inbox reads still qualify: the relay serving them is by definition in the account's own list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compose/AccountDataSourceSubscription.kt | 47 +------ .../authCommand/model/AuthCoordinator.kt | 121 +++++++++++------- .../model/ListWithUniqueSetCache.kt | 6 + .../authCommand/model/RelayAuthFirstParty.kt | 56 ++++++++ .../relayauth/RelayAuthSettingsScreen.kt | 8 +- .../model/RelayAuthFirstPartyTest.kt | 74 +++++++++++ 6 files changed, 216 insertions(+), 96 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index c8a18ce7ef..194c106cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -24,16 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull - -/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */ -private fun venueOwnerPubkey(venueId: String): String? = Address.parse(venueId)?.pubKeyHex @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -45,51 +38,17 @@ fun RelayAuthSubscription( ) { val account = accountViewModel.account + // The per-account NIP-42 policy ledger now lives on Account (account.relayAuthLedger), so this + // only has to register the account itself. The coordinator decides + signs per account. val state = remember(accountViewModel) { ScreenAuthAccount(account) } - val ledger = - remember(accountViewModel) { - RelayAuthPermissionLedger( - store = Amethyst.instance.relayAuthPermissionStore, - globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, - customToggles = { - RelayAuthCustomToggles( - myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value, - readFollows = account.settings.relayAuthTrustReadFollows.value, - messageFollows = account.settings.relayAuthTrustMessageFollows.value, - messageStrangers = account.settings.relayAuthTrustMessageStrangers.value, - ) - }, - isInMyRelayList = { relayUrl -> - val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized in account.trustedRelays.flow.value - }, - isBlocked = { relayUrl -> - val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized in account.blockedRelayList.flow.value - }, - // Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty - // enough to reveal our identity to a relay that serves them. - isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors }, - // A venue (public chat / community / live stream) is trusted if we've joined it, or - // its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow. - isTrustedVenue = { venueId -> - venueId in account.publicChatList.flowSet.value || - venueId in account.communityList.flowSet.value || - venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true - }, - ) - } - - DisposableEffect(state, ledger) { + DisposableEffect(state) { dataSource.subscribe(state) - dataSource.subscribeLedger(ledger) onDispose { dataSource.unsubscribe(state) - dataSource.unsubscribeLedger(ledger) } } } 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..441d09feb2 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 @@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -36,33 +39,19 @@ class ScreenAuthAccount( @Stable class AuthCoordinator( - client: INostrClient, + val client: INostrClient, scope: CoroutineScope, val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(), ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } - private val tempAccount by lazy { - NostrSignerSync() - } - - @Volatile private var relayLedgers: List = emptyList() - - fun subscribeLedger(ledger: RelayAuthPermissionLedger) { - synchronized(this) { relayLedgers = relayLedgers + ledger } - } - - fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) { - synchronized(this) { relayLedgers = relayLedgers - ledger } - } val receiver = RelayAuthenticator( client, scope, signWithAllLoggedInUsers = { 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. + // Reconstruct *why* this relay wants auth from what the shared client is doing with + // it. Built lazily so accounts that fail the first-party gate below don't pay for it. val context by lazy(LazyThreadSafetyMode.NONE) { RelayAuthContext( @@ -74,45 +63,83 @@ class AuthCoordinator( ), ) } - val currentLedgers = relayLedgers - // Ask the user (only in the ASK case) and fold every account's verdict into one - // decision plus an optional per-relay override to remember. - val outcome = - AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) { - promptBus.requestDecision(relayUrl, context.purposes) - } - outcome.remember?.let { decision -> - currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision) - } - val shouldAuth = outcome.shouldAuth - if (shouldAuth) { - // Remember why we granted this relay so the settings screen can explain it. - currentLedgers.firstOrNull()?.recordGrant(context) + // One socket is shared by every logged-in account, so an AUTH challenge is not tied + // to any single one of them. We answer PER ACCOUNT: an account only reveals its + // identity to a relay it has a first-party reason to be on (its own inbox/outbox + // traffic, or a relay it configured) AND its own ledger verdict allows it. This is + // what stops account B — or a throwaway key — being billed / de-anonymized on a + // relay only account A uses (the inbox.nostr.wine over-AUTH bug): unlike the old + // "any account ALLOWs → sign with everyone (else a random key)" path, a bystander + // account never signs, and there is no random-key fallback. + val signed = mutableListOf() + var askChoice: UserAuthChoice? = null - // 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 + authWithAccounts.distinctValues().forEach forEachAccount@{ screen -> + val account = screen.account + if (!account.signer.isWriteable()) return@forEachAccount + if (!isFirstParty(account, relayUrl)) return@forEachAccount + + val approve = + when (account.relayAuthLedger.decide(context)) { + RelayAuthVerdict.ALLOW -> true + RelayAuthVerdict.DENY -> false + RelayAuthVerdict.ASK -> { + // Prompt at most once per challenge; reuse the answer for any other + // account that also reaches ASK on this same relay. + val choice = askChoice ?: promptBus.requestDecision(relayUrl, context.purposes).also { askChoice = it } + when (choice) { + UserAuthChoice.ALLOW_ONCE -> true + UserAuthChoice.ALWAYS_ALLOW -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW) + true + } + UserAuthChoice.BLOCK -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY) + false + } + UserAuthChoice.DISMISS -> false } - } else { - null } } - // Always auth, even with random keys - if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) - } else { - emptyList() + if (approve) { + // Remember why we granted this relay so the settings screen can explain it. + account.relayAuthLedger.recordGrant(context) + try { + signed.add(account.signer.sign(authTemplate)) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + } + } } + + signed }, ) + /** + * True when [account] has a first-party reason to authenticate with [relayUrl] on the shared + * client: it is publishing its own event there, a subscription there is reading its own + * inbox/outbox (`#p` or `authors` names its pubkey), or the relay is in its own relay list. + * + * Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party: + * that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared + * auth context carries the OTHER account's counterparties, evaluated against this account's + * follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't + * use are therefore no longer auto-authed — a deliberate privacy-positive trade-off. + */ + private fun isFirstParty( + account: Account, + relayUrl: NormalizedRelayUrl, + ): Boolean = + RelayAuthFirstParty.hasReason( + me = account.pubKey, + relayUrl = relayUrl, + pendingEvents = client.activeOutboxEvents(relayUrl), + myRelays = account.trustedRelays.flow.value, + ) + fun destroy() { receiver.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt index c48bc6a6b7..af549f6f42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt @@ -54,6 +54,12 @@ class ListWithUniqueSetCache( return newSet } + /** One representative [T] per unique key — the first occurrence wins. */ + fun distinctValues(): List { + val seen = HashSet() + return list.get().filter { seen.add(key(it)) } + } + fun forEachSubscriber(action: (T) -> Unit) { list.get().forEach(action) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt new file mode 100644 index 0000000000..ac6bca8ffe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Whether a given account has a *first-party* reason to authenticate (NIP-42) with a relay on the + * shared [com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient]. Pure so the per-account + * signing gate can be tested without a live client, signer, or Compose. + * + * The socket is shared by every logged-in account, so "this relay wants auth" says nothing about + * *which* account should answer. An account should reveal its identity to a relay only when: + * - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to + * the recipient's inbox relay), or + * - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which + * is where its own inbox/outbox reads are routed anyway). + * + * Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is + * NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans + * it out to the union of everyone's relays, so account B's pubkey routinely rides a subscription to + * account A's paid relay. Trusting that dragged bystander accounts (e.g. into inbox.nostr.wine's + * AUTH, and its bill). Genuine own-inbox reads still qualify via [myRelays] — the relay serving them + * is by definition in the account's own list. + */ +object RelayAuthFirstParty { + fun hasReason( + me: HexKey, + relayUrl: NormalizedRelayUrl, + pendingEvents: List, + myRelays: Set, + ): Boolean { + if (pendingEvents.any { it.pubKey == me }) return true + return relayUrl in myRelays + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 5a244b6b1e..0b57347215 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -64,17 +64,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.LoadRelayAuthUser -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -105,8 +103,8 @@ fun RelayAuthSettingsScreen( nav: INav, ) { val account = accountViewModel.account - val store: DataStoreRelayAuthPermissionStore = Amethyst.instance.relayAuthPermissionStore - val ledger = remember { RelayAuthPermissionLedger(store, { account.settings.defaultRelayAuthPolicy.value }) } + val store: RelayAuthPermissionStore = account.relayAuthPermissions + val ledger = account.relayAuthLedger val scope = rememberCoroutineScope() val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt new file mode 100644 index 0000000000..817fb6defd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The per-account NIP-42 signing gate: on the shared client, an account signs a relay's AUTH only + * when the relay is in its own relay list, or it is publishing its own event there. The bug these + * lock down: an unpaid/bystander account being AUTH'd (and billed by inbox.nostr.wine) purely + * because another account uses that relay — including via a merged filter that names it. + */ +class RelayAuthFirstPartyTest { + private val relay = NormalizedRelayUrl("wss://inbox.nostr.wine/") + private val me = "a".repeat(64) + private val other = "b".repeat(64) + + private fun event(pubkey: String) = + Event( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = 0, + kind = 1059, + tags = emptyArray(), + content = "", + sig = "", + ) + + @Test + fun aRelayNotInMyListWithNothingOfMineIsNotFirstParty() { + // The exact inbox.nostr.wine case: a relay another account uses, which a merged read filter + // names me on, but which is in none of my lists and where I publish nothing → must NOT sign. + assertFalse(RelayAuthFirstParty.hasReason(me, relay, emptyList(), emptySet())) + } + + @Test + fun publishingSomeoneElsesEventIsNotFirstParty() { + assertFalse(RelayAuthFirstParty.hasReason(me, relay, listOf(event(other)), emptySet())) + } + + @Test + fun aRelayIConfiguredIsFirstParty() { + // Own inbox/outbox reads qualify this way: the relay serving them is in my own relay list. + assertTrue(RelayAuthFirstParty.hasReason(me, relay, emptyList(), setOf(relay))) + } + + @Test + fun publishingMyOwnEventIsFirstParty() { + // Delivering my own DM/post to the recipient's relay, even one not in my list. + assertTrue(RelayAuthFirstParty.hasReason(me, relay, listOf(event(me)), emptySet())) + } +} From 6a528a742c37a27c9f4a9dc1de74876e49156807 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:35 -0400 Subject: [PATCH 3/3] fix(relay-auth): show a relay's payment NOTIFY only under the billed account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay NOTIFY (payment prompt) went into a process-wide NotifyRequestsCache and DisplayNotifyMessages showed it under any account whose relay list contained the relay — so a prompt billed to Amethyst surfaced under Vitor, and stale entries re-appeared on every account switch. A NOTIFY doesn't reliably name a pubkey, so instead of parsing the message we correlate it with the AUTH that triggered it: a paid relay answers an unauthorized AUTH with `OK false …` right before the NOTIFY, and we signed that auth event. NotifyCoordinator remembers each auth event's signer, maps the failing OK back to it, and files the NOTIFY into THAT account's own Account.relayNotifications. Unattributable NOTIFYs are dropped. DisplayNotifyMessages now reads only the current account's cache. Also removes the now-unused app-wide RelayAuthPermissionStore singleton and wires NotifyCoordinator with the pubkey -> Account lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 ++- .../compose/DisplayNotifyMessages.kt | 20 +--- .../notifyCommand/model/NotifyCoordinator.kt | 92 +++++++++++++++++-- 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34ade716a5..8b633a1fb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -79,7 +79,6 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -571,13 +570,13 @@ class AppModules( // Verifies and inserts in the cache from all relays, all subscriptions val cacheClientConnector = CacheClientConnector(client, cache) - // Show messages from the Relay and controls their dismissal - val notifyCoordinator = NotifyCoordinator(client) + // Show messages from the Relay and controls their dismissal. Attributes each NOTIFY to the + // account whose AUTH the relay rejected (accountsCache is declared below; the lambda reads it + // lazily at NOTIFY time, long after init). + val notifyCoordinator = NotifyCoordinator(client) { pubkey -> accountsCache.accounts.value[pubkey] } - // Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts. - val relayAuthPermissionStore by lazy { - DataStoreRelayAuthPermissionStore(appContext) - } + // Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions, + // backed by a file under accounts//), so there is no app-wide store here anymore. // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 38f6d6566a..f442bc3ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -21,22 +21,19 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import kotlinx.coroutines.flow.map @Composable fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, -) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav) +) = DisplayNotifyMessages(accountViewModel.account.relayNotifications, accountViewModel, nav) @Composable fun DisplayNotifyMessages( @@ -44,17 +41,10 @@ fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, ) { - val flow = - remember(accountViewModel) { - requests.transientPaymentRequests.map { - it.filter { notifyMsg -> - notifyMsg.relayUrl in accountViewModel.account.dmRelayList.flow.value || - notifyMsg.relayUrl in accountViewModel.account.nip65RelayList.allFlowNoDefaults.value - } - } - } - - val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet()) + // [requests] is THIS account's own cache. NotifyCoordinator only files a NOTIFY under the account + // whose AUTH the relay rejected, so there is no cross-account leak to filter out here — a prompt + // in this cache genuinely belongs to this account. + val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle() openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 404015a6b3..92b40d456e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -20,20 +20,98 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import android.util.LruCache +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayNotifier +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import java.util.concurrent.ConcurrentHashMap +/** + * Routes relay `NOTIFY` payment prompts to the account they actually concern. + * + * A relay's NOTIFY does not reliably name a pubkey, so we can't parse the account out of the + * message. Instead we correlate it with the AUTH that triggered it: a paid relay answers an + * unauthorized AUTH with `OK false …` immediately followed by the NOTIFY. We *signed* + * that auth event, so [AuthCmd.event] tells us which account's key it was. We remember that per auth + * event, resolve the failing `OK` back to the signer, and drop the NOTIFY into THAT account's own + * [NotifyRequestsCache] ([Account.relayNotifications]). + * + * The cache is per account (not a process-wide singleton), so a prompt for account A can never + * surface under account B — and an unattributable NOTIFY is dropped rather than shown to the wrong + * account. This is the fix for the stale-global-cache leak where switching accounts re-surfaced + * another account's inbox.nostr.wine prompt. + */ class NotifyCoordinator( - client: INostrClient, + private val client: INostrClient, + private val accountForPubkey: (HexKey) -> Account?, ) { - val requests = NotifyRequestsCache() + companion object { + const val TAG = "NotifyCoordinator" + private const val AUTH_EVENT_CACHE = 256 + } - val receiver = - RelayNotifier(client) { message, relay -> - requests.addPaymentRequestIfNew(message, relay.url) + // authEventId -> the pubkey we signed it with. Bounded: only a handful of relays re-auth. + private val signerOfAuthEvent = LruCache(AUTH_EVENT_CACHE) + + // relay -> pubkey of the auth the relay most recently rejected there (the one it will bill). + private val billedPubkeyAt = ConcurrentHashMap() + + private val listener = + object : RelayConnectionListener { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is AuthCmd) { + signerOfAuthEvent.put(cmd.event.id, cmd.event.pubKey) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is OkMessage -> + if (!msg.success) { + signerOfAuthEvent.get(msg.eventId)?.let { billedPubkeyAt[relay.url] = it } + } + is NotifyMessage -> route(relay.url, msg.message) + else -> {} + } + } } + private fun route( + relay: NormalizedRelayUrl, + message: String, + ) { + // Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution. + // An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than + // risk surfacing it under the wrong account. + val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) + account?.relayNotifications?.addPaymentRequestIfNew(message, relay) + } + + init { + Log.d(TAG, "Init, Subscribe") + client.addConnectionListener(listener) + } + fun destroy() { - receiver.destroy() + Log.d(TAG, "Destroy, Unsubscribe") + client.removeConnectionListener(listener) } }