diff --git a/amethyst/plans/2026-08-03-auth-permissions-redesign.md b/amethyst/plans/2026-08-03-auth-permissions-redesign.md index ae69a30b65..295de64b98 100644 --- a/amethyst/plans/2026-08-03-auth-permissions-redesign.md +++ b/amethyst/plans/2026-08-03-auth-permissions-redesign.md @@ -51,6 +51,12 @@ Shipped as designed. Where it diverged or went further: connection forever. A second challenge for the same (relay, account) rides along on the owner's answer with no deadline of its own — running one would let it resolve the shared deferred and tear down a dialog mid-read. +- **Corrected later:** this plan left the decision model alone, including the + blanket `isFirstParty` gate on `CUSTOM`. That gate turned out to make + `readFollows` ("…I'm reading someone I follow") unreachable — a follow's outbox + relay is theirs, so it is never first-party for us, and every follow produced a + prompt with the toggle explicitly on. `RelayAuthResolver.customAllows` now + checks that one category ahead of the gate; the other three still require it. - **Still not done:** what a timeout should *look like*. It is now an honest 60s of visible time rather than a clock the user never saw, but it is still a dialog that vanishes and an event left pending in the outbox with no feedback. That 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 7ff6533dc3..91c3e0a791 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 @@ -103,6 +103,10 @@ class AuthCoordinator( // question. Returning early here instead made "decide per relay" mean "deny, and // don't mention it" for every purpose that names someone else — the exact case the // prompt was built to explain. + // + // It does not reach the "…I'm reading someone I follow" toggle at all: a follow's + // outbox relay can never be first-party for us, so applying it there emptied the + // category instead of narrowing it. RelayAuthResolver.customAllows has the detail. val firstParty = isFirstParty(account, relayUrl) val approve = @@ -115,8 +119,9 @@ class AuthCoordinator( // 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: isFirstParty already - // drops every account without its own reason to be on this relay. + // 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 @@ -231,8 +236,13 @@ class AuthCoordinator( * 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. + * follow graph). + * + * Reading a followed author's outbox is the one case this cannot speak to. That relay is the + * author's, so nothing here can ever return true for it, which is why + * [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver] applies the + * [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles.readFollows] category + * without consulting this — otherwise the toggle would be permanently off. */ private fun isFirstParty( account: Account, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt new file mode 100644 index 0000000000..4d41a8b9f3 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt @@ -0,0 +1,128 @@ +/* + * 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.AuthPurpose +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles +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.commons.relayauth.RelayAuthVerdict +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * "…I'm reading someone I follow" has to actually cover the relays it is about. + * + * The whole point of the toggle is the outbox relay of somebody else — a relay we do not publish to, + * do not read our own inbox from, and do not list. That is exactly the shape `isFirstParty` reports + * false for, so requiring it emptied the category: with the toggle explicitly on, every one of the + * user's follows still produced a login prompt for its outbox relay. + */ +class RelayAuthReadFollowsTest { + private val followsRelay = "wss://outbox.someone-i-follow.example/" + private val followed = "a".repeat(64) + private val stranger = "b".repeat(64) + + private class NoStore : RelayAuthPermissionStore { + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) = Unit + + override suspend fun clearDecision(relayUrl: String) = Unit + + override suspend fun allDecisions(): Map = emptyMap() + } + + private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) = + RelayAuthPermissionLedger( + store = NoStore(), + globalPolicy = { RelayAuthPolicy.CUSTOM }, + customToggles = { toggles }, + isFollowed = { it == followed }, + ) + + private fun readOutbox(vararg authors: String) = RelayAuthContext(followsRelay, listOf(AuthPurpose(AuthPurposeKind.READ_OUTBOX, authors.toSet()))) + + @Test + fun readingAFollowAutoAuthenticatesOnTheirOwnOutboxRelay() = + runTest { + // isFirstParty = false is not an edge case here, it is *the* case: the relay belongs to the + // author we are reading. Before the fix this returned ASK, so a user on "decide per relay" + // with this toggle on was prompted once per follow. + assertEquals( + RelayAuthVerdict.ALLOW, + ledger().decide(readOutbox(followed), isFirstParty = false), + ) + } + + @Test + fun readingAFollowStillAsksWhenTheToggleIsOff() = + runTest { + val off = RelayAuthCustomToggles(readFollows = false) + assertEquals( + RelayAuthVerdict.ASK, + ledger(off).decide(readOutbox(followed), isFirstParty = false), + ) + } + + @Test + fun readingAStrangerStillAsks() = + runTest { + // There is deliberately no "read strangers" category — browsing a profile we don't follow + // on a relay of theirs is still a question. + assertEquals( + RelayAuthVerdict.ASK, + ledger().decide(readOutbox(stranger), isFirstParty = false), + ) + } + + @Test + fun oneFollowInABatchedReadIsEnough() = + runTest { + // Outbox reads are batched per relay, so a single filter routinely names a mix. One + // followed author in it is the reason we are on this relay at all. + assertEquals( + RelayAuthVerdict.ALLOW, + ledger().decide(readOutbox(stranger, followed), isFirstParty = false), + ) + } + + @Test + fun messagingIsNotCoveredByTheReadExemption() = + runTest { + // Delivering to a followed user's *inbox* keeps the first-party gate: the pending event + // would be ours, and when it isn't, the traffic belongs to another logged-in account. + val ctx = + RelayAuthContext( + followsRelay, + listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(followed))), + ) + assertEquals(RelayAuthVerdict.ASK, ledger().decide(ctx, isFirstParty = false)) + assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(ctx, isFirstParty = true)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt index 3d095ca38b..f3dd99dae5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt @@ -62,7 +62,8 @@ data class RelayAuthCustomToggles( * there, a subscription there reads its own inbox/outbox, or the relay is in its own relay list. * False means the only reason we are here belongs to somebody else (another logged-in account's * traffic, or a followed author whose outbox happens to live here). Gates the *automatic* grants - * only: a non-first-party challenge is never auto-allowed, but it still reaches the user as a + * only, and only for the categories it can gate without emptying them (see [RelayAuthResolver]): + * a non-first-party challenge is never auto-allowed there, but it still reaches the user as a * prompt rather than a silent denial. */ data class RelayAuthInputs( @@ -92,12 +93,17 @@ data class RelayAuthInputs( * else fall through * 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY. * - * The [RelayAuthPolicy.CUSTOM] grant additionally requires [RelayAuthInputs.isFirstParty]: under + * Most [RelayAuthPolicy.CUSTOM] grants additionally require [RelayAuthInputs.isFirstParty]: under * "decide per relay" an account never reveals its identity *without being asked* on a relay it has no * reason of its own to be on, which is what keeps a bystander account off a relay only another account * uses. It deliberately does not suppress the question — a non-first-party challenge we can explain * falls through to ASK, so the user decides rather than getting a silent denial they never see. * + * [RelayAuthCustomToggles.readFollows] is the one category exempt from that gate, because the gate is + * unsatisfiable there rather than merely strict: reading a followed author means talking to *their* + * outbox relay, which is by definition not one we publish to, subscribe to for our own inbox, or list. + * See [customAllows]. + * * [RelayAuthPolicy.ALWAYS] is NOT gated this way: it means what it says, every relay that asks. Users * who want the narrower "only the relays I actually use" behaviour choose CUSTOM. */ @@ -120,14 +126,37 @@ object RelayAuthResolver { // a large follow list, produced a prompt for each of the 250+ third-party outbox relays. RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW RelayAuthPolicy.CUSTOM -> - if (inputs.isFirstParty && customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs) + if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs) } } + /** + * Whether an enabled [RelayAuthCustomToggles] category covers this relay. + * + * [RelayAuthCustomToggles.readFollows] is checked *before* the [RelayAuthInputs.isFirstParty] + * gate because that gate is unsatisfiable for it, not merely strict. "I'm reading someone I + * follow" describes their outbox relay: not one we publish to, not one serving our own + * inbox/outbox, not one on our list — so `isFirstParty` is false by construction and gating the + * category made it unreachable. Every follow's outbox relay prompted even with the toggle on, and + * the only challenges it ever granted were ones `myRelaysAndVenues` already covered. + * + * Exempting it is safe in the way the gate is meant to be: the follow graph consulted is *this* + * account's, so no other account's traffic can conjure a match. What it can match is another + * logged-in account reading an author we follow too — and the cost of that is an AUTH on a relay + * we would be reading that same author from anyway, which is what the toggle asks for. + * + * Every other category keeps the gate, where it costs them nothing: our own relay list and our + * joined rooms' hosts are first-party by definition, and a pending event of ours makes its + * destination first-party too. That is precisely what stops a bystander account being + * auto-authenticated — and billed — on a paid inbox relay because *another* account's outgoing + * DM happens to name someone we follow. + */ private fun customAllows(inputs: RelayAuthInputs): Boolean { val t = inputs.toggles + if (t.readFollows && inputs.servesFollowedReadCounterparty) return true + if (!inputs.isFirstParty) return false + return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) || - (t.readFollows && inputs.servesFollowedReadCounterparty) || (t.messageFollows && inputs.servesFollowedWriteCounterparty) || (t.messageStrangers && inputs.servesStrangerWriteCounterparty) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt index 9b97ddeea4..485344e630 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt @@ -98,6 +98,31 @@ class RelayAuthResolverTest { ) } + @Test + fun readFollowsGrantsOnTheFollowsOwnOutboxRelay() { + // The situation the toggle is *named for*: someone we follow publishes to a relay of theirs + // that we do not use. `isFirstParty` is false by construction there — the relay is theirs, we + // have no traffic of our own on it — so gating this category on it made "…I'm reading someone + // I follow" unreachable: every follow's outbox relay prompted, on an account with the toggle + // explicitly on. The only time it ever granted was when the relay was also on our own list, + // where `myRelaysAndVenues` already covered it. + assertEquals( + RelayAuthVerdict.ALLOW, + resolve(inputs(servesFollowedReadCounterparty = true, isFirstParty = false)), + ) + // Still off when the toggle is off. + assertEquals( + RelayAuthVerdict.ASK, + resolve( + inputs( + servesFollowedReadCounterparty = true, + isFirstParty = false, + toggles = RelayAuthCustomToggles(readFollows = false), + ), + ), + ) + } + @Test fun customMessageFollowsToggleGatesMessagingFollows() { assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true))) @@ -140,9 +165,22 @@ class RelayAuthResolverTest { val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesTrustedVenue = true, isFirstParty = false))) - assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedReadCounterparty = true, isFirstParty = false))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedWriteCounterparty = true, isFirstParty = false))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesStrangerWriteCounterparty = true, isFirstParty = false))) + // readFollows is deliberately absent: see readFollowsGrantsOnTheFollowsOwnOutboxRelay. Its + // relay is the *follow's*, never ours, so the gate could only ever empty the category. + } + + @Test + fun readFollowsExemptionDoesNotLeakIntoTheOtherCategories() { + // Only the read category is exempt. With readFollows on but nothing being read from a follow, + // a non-first-party relay still asks for every other reason it might want us. + val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false))) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesFollowedWriteCounterparty = true, isFirstParty = false))) + // The bystander case the gate exists for: another account's outgoing DM names someone we + // follow. Ours is not the traffic, so we do not sign for it without being asked. + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, servesStrangerWriteCounterparty = true, isFirstParty = false))) } @Test @@ -158,7 +196,8 @@ class RelayAuthResolverTest { @Test fun customPolicyStillRequiresFirstParty() { // The first-party gate belongs to CUSTOM: a toggle that matches is not enough if the only reason - // we are on this relay belongs to somebody else. + // we are on this relay belongs to somebody else. (Except readFollows, whose relay always + // belongs to the follow — see readFollowsGrantsOnTheFollowsOwnOutboxRelay.) val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = true))) assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, isInMyRelayList = true, isFirstParty = false)))