mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+74
@@ -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,
|
||||
}
|
||||
+8
@@ -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. */
|
||||
|
||||
+94
@@ -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
|
||||
}
|
||||
+101
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user