From 4bd73596e373c8c29884419fa97bbfe66ce2ef86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 15:15:53 +0000 Subject: [PATCH] feat(relayauth): add purpose-aware AUTH policy core + TRUSTED_FOLLOWS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the platform-neutral decision core for contextual NIP-42 auth in commons/relayauth: - AuthPurpose / AuthPurposeKind / RelayAuthContext describe *why* a relay wants auth (send DM, deliver notification, read outbox, own relay). - RelayAuthVerdict adds an ASK outcome (runtime-only; never persisted, unlike the two-value RelayAuthDecision override). - RelayAuthResolver is a pure, unit-tested precedence ladder: blocked list > per-relay override > policy > ASK-if-attributable-else-DENY. - New TRUSTED_FOLLOWS policy: auto-auth for relays serving a followed counterparty on a write purpose (DMs/notifications), and — behind a read sub-toggle — read purposes; strangers fall through to ASK. Wires the new enum value through the existing settings screen (new option + strings, reusing the Group symbol) and the URL-only ledger path (degrades to the my-list check until challenge context is plumbed). Live prompt UI, grant-rationale persistence, and quartz challenge-context plumbing are follow-up steps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a --- .../model/RelayAuthPermissionLedger.kt | 5 + .../relayauth/RelayAuthSettingsScreen.kt | 6 ++ amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/relayauth/AuthPurpose.kt | 74 +++++++++++++ .../commons/relayauth/RelayAuthPolicy.kt | 8 ++ .../commons/relayauth/RelayAuthResolver.kt | 94 ++++++++++++++++ .../relayauth/RelayAuthResolverTest.kt | 101 ++++++++++++++++++ 7 files changed, 290 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt index e7c5c69ca2..63be539e7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -47,6 +47,11 @@ class RelayAuthPermissionLedger( RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY RelayAuthPolicy.IF_IN_MY_LIST -> if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY + // This URL-only entry point has no purpose/counterparty context, so it can only + // apply the "in my list" half of TRUSTED_FOLLOWS. The follow-graph half runs in the + // context-aware path (RelayAuthResolver) once the challenge purpose is known. + RelayAuthPolicy.TRUSTED_FOLLOWS -> + if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index b228c4812e..236e9d65a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -128,6 +128,12 @@ fun RelayAuthSettingsScreen( R.string.relay_auth_policy_if_in_my_list_desc, MaterialSymbols.PrivacyTip, ) + RelayAuthPolicy.TRUSTED_FOLLOWS -> + Triple( + R.string.relay_auth_policy_trusted_follows, + R.string.relay_auth_policy_trusted_follows_desc, + MaterialSymbols.Group, + ) } PolicyCard( selected = globalPolicy == policy, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f275053e00..64807cff9f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -825,6 +825,8 @@ Ignore auth challenges from all relays My relays only Only authenticate with relays in your relay list + My relays and people I follow + Also authenticate with relays that serve people you follow, such as sending a message to a friend. You\'ll be asked about anyone else. Per-relay overrides No per-relay overrides — global policy applies everywhere Allow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt new file mode 100644 index 0000000000..37986c581e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt @@ -0,0 +1,74 @@ +/* + * 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.commons.relayauth + +/** + * Why Amethyst is about to authenticate (NIP-42) with a relay. Carried to the decision + * point so we can (a) tell the user *why* an auth is requested and (b) apply follow-based + * trust against the counterparties a relay serves. + * + * Counterparty pubkeys are stored as hex strings to keep this module free of protocol types; + * display names/avatars are resolved elsewhere at render time. + */ +enum class AuthPurposeKind { + /** Delivering a NIP-17 private message to a recipient's DM inbox (kind 10050). */ + SEND_DM, + + /** Delivering a public reply/mention/reaction to a recipient's NIP-65 inbox (read relays). */ + NOTIFY_INBOX, + + /** Reading an author's posts from their NIP-65 outbox (write relays). */ + READ_OUTBOX, + + /** The relay is in the user's own relay list. */ + MY_OWN_RELAY, +} + +/** + * A single reason a relay connection needs auth, with the counterparties it concerns. + * [counterparties] is empty for [AuthPurposeKind.MY_OWN_RELAY]. + */ +data class AuthPurpose( + val kind: AuthPurposeKind, + val counterparties: Set = emptySet(), +) + +/** The relay plus every live reason we currently have to auth with it. */ +data class RelayAuthContext( + val relayUrl: String, + val purposes: List = emptyList(), +) + +/** + * The runtime verdict for an auth challenge. Distinct from [RelayAuthDecision], which is the + * two-value ([RelayAuthDecision.ALLOW]/[RelayAuthDecision.DENY]) *persisted* per-relay override: + * [ASK] is only ever a live decision, never stored. + */ +enum class RelayAuthVerdict { + /** Sign and send the NIP-42 auth event. */ + ALLOW, + + /** Do not auth; do not reveal identity. */ + DENY, + + /** Prompt the user, explaining the purpose, before deciding. */ + ASK, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt index 4287267c36..abedb87a2c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt @@ -33,6 +33,14 @@ enum class RelayAuthPolicy { /** Authenticate only with relays explicitly listed in the user's relay list. */ IF_IN_MY_LIST, + + /** + * Authenticate with relays in the user's own list, and additionally with relays that + * serve someone the user follows (any follow list) for the current purpose — e.g. the + * DM inbox of a friend you're messaging. Relays that can't be attributed to a followed + * counterparty fall through to an explicit prompt ([RelayAuthVerdict.ASK]). + */ + TRUSTED_FOLLOWS, } /** A persisted per-relay override decision. */ 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 new file mode 100644 index 0000000000..900129395c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt @@ -0,0 +1,94 @@ +/* + * 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.commons.relayauth + +/** + * Everything the resolver needs to decide an auth challenge, gathered by the host (which owns + * the blocked-relay list, the user's relay lists, and the follow graph). Kept as plain values + * so the decision itself is pure and unit-testable without any account/relay wiring. + * + * @param storedOverride an explicit per-relay decision the user set previously, or null. + * @param isBlocked the relay is on the user's blocked-relay list (kind 10006). + * @param policy the global [RelayAuthPolicy]. + * @param isInMyRelayList the relay is in the user's own relay list. + * @param servesFollowedWriteCounterparty a followed user is a counterparty of a *write* purpose + * (send DM / deliver notification) for this relay. + * @param servesFollowedReadCounterparty a followed user is a counterparty of a *read* purpose + * (download their outbox) for this relay. + * @param readTrustEnabled the "also trust follows' outboxes when reading" sub-toggle. + * @param hasAttributablePurpose we know *why* this relay wants auth (so a prompt can explain it). + * When false, an unresolved challenge is denied silently rather than prompting. + */ +data class RelayAuthInputs( + val storedOverride: RelayAuthDecision?, + val isBlocked: Boolean, + val policy: RelayAuthPolicy, + val isInMyRelayList: Boolean, + val servesFollowedWriteCounterparty: Boolean, + val servesFollowedReadCounterparty: Boolean, + val readTrustEnabled: Boolean, + val hasAttributablePurpose: Boolean, +) + +/** + * Pure NIP-42 auth decision. Precedence, highest first: + * + * 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay). + * 2. Explicit per-relay override → honor it. + * 3. Global [RelayAuthPolicy]: + * - [RelayAuthPolicy.NEVER] → DENY + * - [RelayAuthPolicy.ALWAYS] → ALLOW + * - [RelayAuthPolicy.IF_IN_MY_LIST] → ALLOW if in my list, else fall through + * - [RelayAuthPolicy.TRUSTED_FOLLOWS] → ALLOW if in my list, or a followed counterparty is + * served for a write purpose (DM/notification), or (when [RelayAuthInputs.readTrustEnabled]) + * for a read purpose; else fall through + * 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY. + */ +object RelayAuthResolver { + fun resolve(inputs: RelayAuthInputs): RelayAuthVerdict { + if (inputs.isBlocked) return RelayAuthVerdict.DENY + + inputs.storedOverride?.let { + return when (it) { + RelayAuthDecision.ALLOW -> RelayAuthVerdict.ALLOW + RelayAuthDecision.DENY -> RelayAuthVerdict.DENY + } + } + + return when (inputs.policy) { + RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY + RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW + RelayAuthPolicy.IF_IN_MY_LIST -> + if (inputs.isInMyRelayList) RelayAuthVerdict.ALLOW else fallThrough(inputs) + RelayAuthPolicy.TRUSTED_FOLLOWS -> + if (inputs.isInMyRelayList || + inputs.servesFollowedWriteCounterparty || + (inputs.readTrustEnabled && inputs.servesFollowedReadCounterparty) + ) { + RelayAuthVerdict.ALLOW + } else { + fallThrough(inputs) + } + } + } + + private fun fallThrough(inputs: RelayAuthInputs): RelayAuthVerdict = if (inputs.hasAttributablePurpose) RelayAuthVerdict.ASK else RelayAuthVerdict.DENY +} 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 new file mode 100644 index 0000000000..b19434e1f5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt @@ -0,0 +1,101 @@ +/* + * 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.commons.relayauth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RelayAuthResolverTest { + private fun inputs( + storedOverride: RelayAuthDecision? = null, + isBlocked: Boolean = false, + policy: RelayAuthPolicy = RelayAuthPolicy.TRUSTED_FOLLOWS, + isInMyRelayList: Boolean = false, + servesFollowedWriteCounterparty: Boolean = false, + servesFollowedReadCounterparty: Boolean = false, + readTrustEnabled: Boolean = false, + hasAttributablePurpose: Boolean = true, + ) = RelayAuthInputs( + storedOverride = storedOverride, + isBlocked = isBlocked, + policy = policy, + isInMyRelayList = isInMyRelayList, + servesFollowedWriteCounterparty = servesFollowedWriteCounterparty, + servesFollowedReadCounterparty = servesFollowedReadCounterparty, + readTrustEnabled = readTrustEnabled, + hasAttributablePurpose = hasAttributablePurpose, + ) + + private fun resolve(inputs: RelayAuthInputs) = RelayAuthResolver.resolve(inputs) + + @Test + fun blockedRelayAlwaysDeniesEvenWithAllowOverrideAndAlwaysPolicy() { + assertEquals( + RelayAuthVerdict.DENY, + resolve( + inputs( + isBlocked = true, + storedOverride = RelayAuthDecision.ALLOW, + policy = RelayAuthPolicy.ALWAYS, + ), + ), + ) + } + + @Test + fun explicitOverrideBeatsPolicy() { + assertEquals(RelayAuthVerdict.DENY, resolve(inputs(storedOverride = RelayAuthDecision.DENY, policy = RelayAuthPolicy.ALWAYS))) + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(storedOverride = RelayAuthDecision.ALLOW, policy = RelayAuthPolicy.NEVER))) + } + + @Test + fun neverAndAlwaysAreUnconditional() { + assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, servesFollowedWriteCounterparty = true))) + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.ALWAYS, hasAttributablePurpose = false))) + } + + @Test + fun ifInMyListAllowsOnlyMyRelaysElseAsksWhenAttributable() { + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.IF_IN_MY_LIST, isInMyRelayList = true))) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(policy = RelayAuthPolicy.IF_IN_MY_LIST, isInMyRelayList = false))) + } + + @Test + fun trustedFollowsAllowsWriteToFollowedCounterparty() { + // Sending a DM / delivering a notification to someone I follow -> auto-auth. + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true))) + } + + @Test + fun trustedFollowsDoesNotAutoAllowReadUnlessSubToggleOn() { + // Reading a followed author's outbox: prompts by default (decision D, conservative)... + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesFollowedReadCounterparty = true, readTrustEnabled = false))) + // ...auto-auths only when the read sub-toggle is enabled. + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true, readTrustEnabled = true))) + } + + @Test + fun trustedFollowsFallsThroughForStranger() { + // Not my relay, no followed counterparty -> prompt when we know why, else silent deny. + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(hasAttributablePurpose = true))) + assertEquals(RelayAuthVerdict.DENY, resolve(inputs(hasAttributablePurpose = false))) + } +}