Merge pull request #3543 from vitorpamplona/fix/inbox-nostr-wine-over-auth

fix(relay-auth): stop paid relays billing accounts that don't use them (inbox.nostr.wine over-AUTH)
This commit is contained in:
Vitor Pamplona
2026-07-12 21:05:35 -04:00
committed by GitHub
14 changed files with 548 additions and 130 deletions
@@ -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/<pubkey>/), 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) }
@@ -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)
@@ -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))
@@ -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)
}
}
}
@@ -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<ScreenAuthAccount, Account> { it.account }
private val tempAccount by lazy {
NostrSignerSync()
}
@Volatile private var relayLedgers: List<RelayAuthPermissionLedger> = 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<RelayAuthEvent>()
var askChoice: UserAuthChoice? = null
// distinct() returns Set<Account> (the key type U of ListWithUniqueSetCache)
val results =
authWithAccounts.distinct().mapNotNull {
if (it.signer.isWriteable()) {
try {
it.signer.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
null
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()
}
@@ -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<Preferences> 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<Preferences> 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<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
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:"
@@ -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<String, RelayAuthDecision>()
private val rationale = mutableMapOf<String, MutableMap<AuthPurposeKind, MutableSet<String>>>()
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<String, RelayAuthDecision> = decisions.toMap()
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {
val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() }
for ((kind, pubkeys) in additions) {
forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys)
}
}
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap()
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } }
override suspend fun clearRationale(relayUrl: String) {
rationale.remove(relayUrl)
}
}
@@ -54,6 +54,12 @@ class ListWithUniqueSetCache<T, U>(
return newSet
}
/** One representative [T] per unique key — the first occurrence wins. */
fun distinctValues(): List<T> {
val seen = HashSet<U>()
return list.get().filter { seen.add(key(it)) }
}
fun forEachSubscriber(action: (T) -> Unit) {
list.get().forEach(action)
}
@@ -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<Event>,
myRelays: Set<NormalizedRelayUrl>,
): Boolean {
if (pendingEvents.any { it.pubKey == me }) return true
return relayUrl in myRelays
}
}
@@ -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/<pubkey>/`). 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<Unit>()
private val _overrides = MutableStateFlow<Map<String, RelayAuthDecision>>(emptyMap())
/** Per-relay overrides for this account, observable so the settings screen refreshes on change. */
val overrides: StateFlow<Map<String, RelayAuthDecision>> = _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<String, RelayAuthDecision> {
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<AuthPurposeKind, Set<String>>,
) = disk.recordUse(relayUrl, additions)
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = disk.loadRationale(relayUrl)
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = disk.allRationales()
override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl)
override suspend fun allLastUsed(): Map<String, Long> = disk.allLastUsed()
}
@@ -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(
@@ -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 <authEventId> 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<HexKey, HexKey>(AUTH_EVENT_CACHE)
// relay -> pubkey of the auth the relay most recently rejected there (the one it will bill).
private val billedPubkeyAt = ConcurrentHashMap<NormalizedRelayUrl, HexKey>()
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)
}
}
@@ -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()
@@ -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()))
}
}