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/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/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/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/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/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() +} 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) } } 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())) + } +}