diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index a5c9242612..1f4356b4a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap class ScreenAuthAccount( @@ -53,7 +54,7 @@ class ScreenAuthAccount( @Stable class AuthCoordinator( val client: INostrClient, - scope: CoroutineScope, + private val scope: CoroutineScope, val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(), ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } @@ -117,69 +118,34 @@ class AuthCoordinator( when (account.relayAuthLedger.decide(context, firstParty)) { RelayAuthVerdict.ALLOW -> true RelayAuthVerdict.DENY -> false - RelayAuthVerdict.ASK -> { - // Prompt PER ACCOUNT, not once per challenge. The dialog names whose - // npub is about to be revealed, so answering it for @a must not also - // reveal @b — an answer is only about the identity it was shown for. - // The bus still collapses concurrent challenges for the same - // (relay, account) pair, which is the case the shared prompt was for. - // In practice this rarely means two dialogs: for everything except - // reading a follow, isFirstParty already drops every account without - // its own reason to be on this relay. - // - // But never block the derived stream-key AUTH behind that dialog: on a - // relay that hosts our Concord planes we DISMISS the user-auth ASK - // (skip account auth) so the stream AUTHs return immediately instead of - // waiting on a prompt. - // - // A non-[interactive] pass is an automatic re-auth off an `auth-required:` - // CLOSED (e.g. a Concord channel-plane REQ refused because the connection - // AUTHed before the control plane folded in its channel stream keys). It - // must never raise a fresh dialog: DISMISS the account ASK and let only the - // already-approved identities (ledger-ALLOW accounts + stream keys) re-send. - val choice = - if (streamAuths.isNotEmpty() || !interactive) { - UserAuthChoice.DISMISS - } else { - promptBus.requestDecision( - relayUrl = relayUrl, - purposes = context.purposes, - askingAccount = account.pubKey, - isMyOwnRelay = account.relayAuthLedger.isInMyRelayList(relayUrl.url), - ) - } - when (choice) { - UserAuthChoice.ALLOW_ONCE -> { - // Not literally once: relays re-challenge on every reconnect, - // so answering only the in-flight challenge meant the same - // dialog came back minutes later. The grant is kept in memory - // for the rest of this run and dies with the process. - account.relayAuthLedger.grantForSession(relayUrl.url) - true - } - UserAuthChoice.ALWAYS_ALLOW -> { - account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW) - true - } - UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE -> { - // No per-relay decision is stored: the policy already answers this - // relay, and an exception on top of it would survive a later switch - // back to "decide per relay". The UI has normally applied this - // already (see [applyPolicyEverywhere]); repeating it is free. - applyPolicyEverywhere(account.pubKey, RelayAuthPolicy.ALWAYS) - true - } - UserAuthChoice.BLOCK -> { - account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY) + RelayAuthVerdict.ASK -> + when { + // A non-[interactive] pass is an automatic re-auth off an + // `auth-required:` CLOSED (e.g. a Concord channel-plane REQ refused + // because the connection AUTHed before the control plane folded in + // its channel stream keys). It never raises a dialog: only identities + // already approved (ledger ALLOWs, session grants, stream keys) re-send. + !interactive -> false + + // On a relay that hosts our Concord planes, the derived stream-key AUTHs + // ride this same answer and must not wait on a dialog. The account's + // question used to be dropped outright here, which left the account + // unauthenticated on every Concord relay: it could not read its own + // gift wraps there (Direct Invites, DMs). Ask it in the background + // instead; an approval re-authenticates with the stored challenge. + streamAuths.isNotEmpty() -> { + askInBackground(account, relayUrl, context) false } - UserAuthChoice.NEVER_ALLOW_EVERYWHERE -> { - applyPolicyEverywhere(account.pubKey, RelayAuthPolicy.NEVER) - false - } - UserAuthChoice.DISMISS -> false + + // Prompt PER ACCOUNT, not once per challenge. The dialog names whose + // npub is about to be revealed, so answering it for @a must not also + // reveal @b. The bus still collapses concurrent challenges for the same + // (relay, account) pair. In practice this rarely means two dialogs: for + // everything except reading a follow, isFirstParty already drops every + // account without its own reason to be on this relay. + else -> applyChoice(account, relayUrl, prompt(account, relayUrl, context)) } - } } if (approve) { @@ -197,6 +163,68 @@ class AuthCoordinator( }, ) + private suspend fun prompt( + account: Account, + relayUrl: NormalizedRelayUrl, + context: RelayAuthContext, + ): UserAuthChoice = + promptBus.requestDecision( + relayUrl = relayUrl, + purposes = context.purposes, + askingAccount = account.pubKey, + isMyOwnRelay = account.relayAuthLedger.isInMyRelayList(relayUrl.url), + ) + + /** Asks about [account] on [relayUrl] without holding up the challenge's other AUTHs; see the ASK branch. */ + private fun askInBackground( + account: Account, + relayUrl: NormalizedRelayUrl, + context: RelayAuthContext, + ) { + scope.launch { + if (applyChoice(account, relayUrl, prompt(account, relayUrl, context))) { + account.relayAuthLedger.recordGrant(context) + receiver.reauthenticate(relayUrl) + } + } + } + + /** Stores what the user chose for [account] on [relayUrl]; true when it means "authenticate". */ + private suspend fun applyChoice( + account: Account, + relayUrl: NormalizedRelayUrl, + choice: UserAuthChoice, + ): Boolean = + when (choice) { + UserAuthChoice.ALLOW_ONCE -> { + // Not literally once: relays re-challenge on every reconnect, so answering only the + // in-flight challenge meant the same dialog came back minutes later. The grant is kept + // in memory for the rest of this run and dies with the process. + account.relayAuthLedger.grantForSession(relayUrl.url) + true + } + UserAuthChoice.ALWAYS_ALLOW -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW) + true + } + UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE -> { + // No per-relay decision is stored: the policy already answers this relay, and an + // exception on top of it would survive a later switch back to "decide per relay". The UI + // has normally applied this already (see [applyPolicyEverywhere]); repeating it is free. + applyPolicyEverywhere(account.pubKey, RelayAuthPolicy.ALWAYS) + true + } + UserAuthChoice.BLOCK -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY) + false + } + UserAuthChoice.NEVER_ALLOW_EVERYWHERE -> { + applyPolicyEverywhere(account.pubKey, RelayAuthPolicy.NEVER) + false + } + UserAuthChoice.DISMISS -> false + } + /** * Sets [askingAccount]'s top-level NIP-42 policy — the "Always, all relays" / "Never, all relays" * answers. Public because the *prompt* calls it the moment the user confirms, instead of relying diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 160e8c13d5..c4749e6e45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -117,6 +117,9 @@ class RelayAuthenticator( /** The challenge each relay was last re-authenticated on because of an EOSE `"auth"` hint. */ private val authHintRetried = LargeCache() + /** The live connection per relay, so [reauthenticate] can reach it by URL. */ + private val connections = LargeCache() + private val _authStateFlow = MutableStateFlow>(persistentMapOf()) /** @@ -152,11 +155,13 @@ class RelayAuthenticator( } override fun onConnecting(relay: IRelayClient) { + connections.put(relay.url, relay) authStatus.put(relay.url, RelayAuthStatus()) publishSnapshot(relay.url) } override fun onDisconnected(relay: IRelayClient) { + connections.remove(relay.url) authStatus.remove(relay.url) authHintRetried.remove(relay.url) publishSnapshot(relay.url) @@ -255,6 +260,16 @@ class RelayAuthenticator( reauthenticateWithStoredChallenge(relay) } + /** + * Re-runs the sign/send pass on [relay]'s stored challenge, never prompting: for an identity + * approved after the relay's challenge was answered without it (a permission the user granted + * from a dialog that did not hold up the rest of that answer). The AUTH's `OK` then re-sends the + * refused REQs. A no-op with no live connection, no stored challenge, or an AUTH in flight. + */ + fun reauthenticate(relay: NormalizedRelayUrl) { + reauthenticateWithStoredChallenge(connections.get(relay) ?: return) + } + private fun reauthenticateWithStoredChallenge(relay: IRelayClient) { val status = authStatus.get(relay.url) ?: return // Coalesce the burst: a relay refuses EVERY currently-open sub with its own `auth-required`