From f8416f4940f65694581093aecc1477e5ce082b29 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:07 -0400 Subject: [PATCH] feat(relay-auth): per-account NIP-42 override store + state on Account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them) lived in one process-wide DataStore keyed only by relay URL, so a DENY set for one account silently applied to every logged-in account. Move them to a per-account file under accounts// and warm-cache the overrides in memory on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered without a disk read. DataStoreRelayAuthPermissionStore now shares one DataStore per file path — DataStore v1 forbids two live instances on one file, and loadAccount can build the store more than once per account. Introduces the per-account state holders wired up by the following commits: Account.relayAuthPermissions, relayAuthLedger and relayNotifications. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 43 +++++++ .../model/accountsCache/AccountCacheState.kt | 6 + .../DataStoreRelayAuthPermissionStore.kt | 19 +++- .../model/InMemoryRelayAuthPermissionStore.kt | 68 ++++++++++++ .../model/RelayAuthPermissionCache.kt | 105 ++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 798aef0e4a..c374cc6d95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory @@ -127,6 +129,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor @@ -170,6 +176,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -339,6 +346,7 @@ class Account( val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, + relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), ) : IAccount { private var userProfileCache: User? = null @@ -353,6 +361,41 @@ class Account( val userMetadata = UserMetadataState(signer, cache, scope, settings) + // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is + // answered without a disk read. Backed by a per-account file (see AccountCacheState). + val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope) + + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), + // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH + // path (foreground screen + background notification consumer) shares one instance, and so an + // AUTH challenge is decided per account instead of folding every logged-in account together. + val relayAuthLedger = + RelayAuthPermissionLedger( + store = relayAuthPermissions, + globalPolicy = { settings.defaultRelayAuthPolicy.value }, + customToggles = { + RelayAuthCustomToggles( + myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value, + readFollows = settings.relayAuthTrustReadFollows.value, + messageFollows = settings.relayAuthTrustMessageFollows.value, + messageStrangers = settings.relayAuthTrustMessageStrangers.value, + ) + }, + isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false }, + isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false }, + isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors }, + isTrustedVenue = { venueId -> + venueId in publicChatList.flowSet.value || + venueId in communityList.flowSet.value || + Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true + }, + ) + + // Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming + // NOTIFY to the account whose AUTH the relay rejected and drops it here, so a prompt for one + // account never surfaces under another (the old cache was a process-wide singleton). + val relayNotifications = NotifyRequestsCache() + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 32a6183eec..7a07f95469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -224,6 +225,10 @@ class AccountCacheState( null } + // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one + // account never leaks into another (the store used to be a single app-wide file). + val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -247,6 +252,7 @@ class AccountCacheState( marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, powQueue = powQueue, + relayAuthPermissionStore = relayAuthPermissionStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt index abdb5c5e86..fe2e79fffd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.first import java.io.File import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap /** * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides @@ -45,11 +46,10 @@ class DataStoreRelayAuthPermissionStore( ) : RelayAuthPermissionStore { constructor(context: Context) : this(context.applicationContext.filesDir) - private val store: DataStore by lazy { - PreferenceDataStoreFactory.create( - produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, - ) - } + // DataStore v1 throws if two instances are ever active on the same file. loadAccount can build + // this store more than once for the same account (re-login, cache races), so the underlying + // DataStore is shared per absolute file path across the process instead of created per instance. + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb")) override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { val raw = store.data.first()[decisionKey(relayUrl)] ?: return null @@ -198,6 +198,15 @@ class DataStoreRelayAuthPermissionStore( private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") companion object { + // One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once + // per path, so concurrent constructions for the same account share a single active DataStore. + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" private const val RATIONALE_PREFIX = "rat:" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..eb4c514f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore + +/** + * Volatile, non-persistent [RelayAuthPermissionStore]. Used as the default for [com.vitorpamplona.amethyst.model.Account] + * instances built without a disk-backed store (Compose previews, unit tests, the mock account view + * models) so nothing on the auth path has to null-check the store. + */ +class InMemoryRelayAuthPermissionStore : RelayAuthPermissionStore { + private val decisions = mutableMapOf() + private val rationale = mutableMapOf>>() + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = decisions[relayUrl] + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + decisions[relayUrl] = decision + } + + override suspend fun clearDecision(relayUrl: String) { + decisions.remove(relayUrl) + } + + override suspend fun allDecisions(): Map = decisions.toMap() + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) { + val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() } + for ((kind, pubkeys) in additions) { + forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys) + } + } + + override suspend fun loadRationale(relayUrl: String): Map> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap() + + override suspend fun allRationales(): Map>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } } + + override suspend fun clearRationale(relayUrl: String) { + rationale.remove(relayUrl) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt new file mode 100644 index 0000000000..4f2ab00362 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * In-memory, warm-cached view over one account's [RelayAuthPermissionStore] (a per-account file — + * see [DataStoreRelayAuthPermissionStore] built from `accounts//`). Held on the + * [com.vitorpamplona.amethyst.model.Account] like the other state caches. + * + * The ALLOW/DENY overrides are the only thing read on the hot NIP-42 decision path + * ([RelayAuthPermissionLedger.decide]). They are snapshotted into memory once, right after the + * account loads, and served from there — so an incoming AUTH challenge is answered without a disk + * read (a slow disk hit here would stall login-time relay auth). Writes update memory *and* disk. + * + * Rationale + last-used are read only by the settings screen, off the hot path, so they pass + * straight through to disk. Implements [RelayAuthPermissionStore] so it drops into every existing + * call site (the ledger and the settings screen) unchanged. + */ +class RelayAuthPermissionCache( + private val disk: RelayAuthPermissionStore, + scope: CoroutineScope, +) : RelayAuthPermissionStore { + private val loaded = CompletableDeferred() + private val _overrides = MutableStateFlow>(emptyMap()) + + /** Per-relay overrides for this account, observable so the settings screen refreshes on change. */ + val overrides: StateFlow> = _overrides.asStateFlow() + + init { + scope.launch { + _overrides.value = disk.allDecisions() + loaded.complete(Unit) + } + } + + /** Non-suspending override lookup — returns null until the initial warm load finishes. */ + fun decisionOrNull(relayUrl: String): RelayAuthDecision? = _overrides.value[relayUrl] + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + loaded.await() + return _overrides.value[relayUrl] + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + disk.storeDecision(relayUrl, decision) + _overrides.update { it + (relayUrl to decision) } + } + + override suspend fun clearDecision(relayUrl: String) { + disk.clearDecision(relayUrl) + _overrides.update { it - relayUrl } + } + + override suspend fun allDecisions(): Map { + loaded.await() + return _overrides.value + } + + // --- rationale + last-used: settings-screen only, never on the auth decision path --- + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) = disk.recordUse(relayUrl, additions) + + override suspend fun loadRationale(relayUrl: String): Map> = disk.loadRationale(relayUrl) + + override suspend fun allRationales(): Map>> = disk.allRationales() + + override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl) + + override suspend fun allLastUsed(): Map = disk.allLastUsed() +}