feat(relayauth): add purpose-aware AUTH policy core + TRUSTED_FOLLOWS

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
This commit is contained in:
Claude
2026-07-10 22:40:09 +00:00
parent 9c618f9ba1
commit 4bd73596e3
7 changed files with 290 additions and 0 deletions
@@ -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
}
}
@@ -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,
+2
View File
@@ -825,6 +825,8 @@
<string name="relay_auth_policy_never_desc">Ignore auth challenges from all relays</string>
<string name="relay_auth_policy_if_in_my_list">My relays only</string>
<string name="relay_auth_policy_if_in_my_list_desc">Only authenticate with relays in your relay list</string>
<string name="relay_auth_policy_trusted_follows">My relays and people I follow</string>
<string name="relay_auth_policy_trusted_follows_desc">Also authenticate with relays that serve people you follow, such as sending a message to a friend. You\'ll be asked about anyone else.</string>
<string name="relay_auth_per_relay_overrides">Per-relay overrides</string>
<string name="relay_auth_no_overrides">No per-relay overrides — global policy applies everywhere</string>
<string name="relay_auth_decision_allow">Allow</string>
@@ -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<String> = emptySet(),
)
/** The relay plus every live reason we currently have to auth with it. */
data class RelayAuthContext(
val relayUrl: String,
val purposes: List<AuthPurpose> = 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,
}
@@ -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. */
@@ -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
}
@@ -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)))
}
}