mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
fix(relay-auth): ask about the account on Concord relays instead of skipping it
On a relay that also hosts one of our Concord planes, an account the ledger had no answer for was never asked: the prompt was dismissed so it would not hold up the stream-key AUTHs riding the same answer. The account then stayed unauthenticated on every such relay without the user ever deciding. Send the stream-key AUTHs at once and ask about the account in the background; an approval re-authenticates on the stored challenge through the new RelayAuthenticator.reauthenticate(relay). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8425527b2
commit
cf73675524
+89
-61
@@ -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<ScreenAuthAccount, Account> { 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
|
||||
|
||||
+15
@@ -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<NormalizedRelayUrl, String>()
|
||||
|
||||
/** The live connection per relay, so [reauthenticate] can reach it by URL. */
|
||||
private val connections = LargeCache<NormalizedRelayUrl, IRelayClient>()
|
||||
|
||||
private val _authStateFlow = MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>(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`
|
||||
|
||||
Reference in New Issue
Block a user