mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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)
|
||||
|
||||
+6
@@ -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))
|
||||
|
||||
+3
-44
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-47
@@ -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()
|
||||
}
|
||||
|
||||
+14
-5
@@ -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:"
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+56
@@ -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
|
||||
}
|
||||
}
|
||||
+105
@@ -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()
|
||||
}
|
||||
+5
-15
@@ -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(
|
||||
|
||||
+85
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -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()
|
||||
|
||||
+74
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user