fix: make the "reading someone I follow" relay-auth toggle reachable

Under the CUSTOM ("decide per relay") policy, RelayAuthResolver AND-gated
every toggle behind isFirstParty:

    if (inputs.isFirstParty && customAllows(inputs)) ALLOW else fallThrough

isFirstParty (RelayAuthFirstParty.hasReason) is true only when we publish to
the relay, the relay is on our own list, or it hosts a room we joined. A
follow's outbox relay is none of those — it is theirs — so the readFollows
branch of customAllows could never be reached. With "…I'm reading someone I
follow" explicitly on, every follow's outbox relay still fell through to ASK,
producing one login prompt per follow. The only challenges the category ever
granted were ones myRelaysAndVenues already covered.

customAllows now checks readFollows ahead of the gate. Exempting just that
category keeps what the gate is for: the follow graph it consults is this
account's, so another account's traffic cannot conjure a match, and the other
three categories still require first-party — which is what stops a bystander
account being auto-authenticated (and billed) on a paid inbox relay because
another logged-in account's outgoing DM happened to name someone we follow.
Those three lose nothing by keeping it: 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.

RelayAuthResolverTest pinned the old behaviour as intended
(nonFirstPartyAsksInsteadOfAutoAllowing), which is why this went unnoticed;
that assertion is replaced by readFollowsGrantsOnTheFollowsOwnOutboxRelay plus
readFollowsExemptionDoesNotLeakIntoTheOtherCategories, and a new
RelayAuthReadFollowsTest covers the same case end-to-end through the ledger.

Verified: 80 relay-auth tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
This commit is contained in:
Claude
2026-08-17 15:16:12 +00:00
parent be2ed3b7f4
commit 06b49acf06
5 changed files with 222 additions and 10 deletions
@@ -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
@@ -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,
@@ -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<String, RelayAuthDecision> = 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))
}
}
@@ -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)
}
@@ -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)))