diff --git a/amethyst/plans/2026-07-01-auth-permission-architecture.md b/amethyst/plans/2026-07-01-auth-permission-architecture.md new file mode 100644 index 0000000000..f3d431047a --- /dev/null +++ b/amethyst/plans/2026-07-01-auth-permission-architecture.md @@ -0,0 +1,385 @@ +# Contextual AUTH Permissions — Ask *why*, and trust follows + +**Date:** 2026-07-01 +**Module:** `amethyst` (+ shared bits in `commons`) +**Status:** Implemented — see "As-built" below for where the shipped design diverged from this proposal. + +## As-built (final) + +The implementation kept this doc's core ideas (purpose derivation, a prompt bus, +per-relay overrides, grant rationale) but the policy model was reshaped during +review: + +- **Global mode is `RelayAuthPolicy { ALWAYS, NEVER, CUSTOM }`** — the earlier + `IF_IN_MY_LIST` / `TRUSTED_FOLLOWS` values were dropped. `CUSTOM` applies a + `RelayAuthCustomToggles` set of independent switches: **my relays & venues**, + **read posts from follows**, **message follows**, **message strangers** + (off by default). New-install default is `CUSTOM` with the first three on. +- **`AuthPurpose` is a `data class` (kind + counterparties + venues)** over an + `AuthPurposeKind` enum (SEND_DM, NOTIFY_INBOX, READ_OUTBOX, POST_VENUE, + READ_VENUE, MY_OWN_RELAY, OTHER) — not a sealed interface. Venues (NIP-28 + public chats, NIP-72 communities, NIP-53 live activities) are first-class. +- **Settings screen** uses the app's settings design system (`SettingsSection` + card + `SettingsSwitchTile`) for the toggles and a grouped, lazily-rendered + per-relay list (NIP-11 icon, `displayUrl`, tap → relay info). +- **Give-up signal**: quartz's outbox surfaces `onEventGaveUp`, toasted by + `RelayPublishFailureToast`. `auth-required` NAKs never burn the retry budget + (they reset it) so a slow AUTH handshake can't drop the event. +- **Known limitation**: an event queued to a relay the user then *denies* stays + pending in the outbox (auth-required never gives up); evicting it would need a + quartz "give up on relay for this event" API — deferred. + +## Context + +Now that Amethyst answers NIP-42 relay AUTH challenges, we need to decide +*when* to reveal the user's identity to a relay — and, crucially, to tell the +user **why** an auth is being requested so they can make an informed choice. + +The motivating cases: + +- **NIP-17 DM send.** The recipient's DM inbox relays (kind 10050) may require + auth. If we silently refuse, the message never leaves the device and the user + has no idea why. We should ask: *"Relay X wants you to log in to deliver your + private message to Alice — allow?"* +- **Public inbox notifications.** Replying to / mentioning / reacting to someone + publishes to *their* NIP-65 inbox (kind 10002 read relays), which may require + auth. +- **Feed download from outboxes.** Reading a followed author's posts may require + auth to *their* write/outbox relays. + +We also want an **automatic mode** for users who trust Amethyst's judgement: +auth (or not) based on a follow-graph heuristic — *if I follow the counterparty +(in any follow list), I trust them enough to reveal my identity to the relay +that serves them.* And regardless of mode, **explicit per-relay overrides** must +be able to force-allow or force-block a single relay. The blocked-relay list +(kind 10006) is a hard block. + +## What already exists (reuse — do NOT rebuild) + +The NIP-42 plumbing and a first-cut permission gate are already in place: + +| Piece | Location | +|---|---| +| AUTH challenge receipt, kind-22242 signing, resend-on-OK | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt`, `RelayAuthStatus.kt`, `nip42RelayAuth/RelayAuthEvent.kt` | +| Permission gate (per logged-in account) | `amethyst/.../service/relayClient/authCommand/model/AuthCoordinator.kt` | +| Decision engine (per-relay override → global policy) | `.../authCommand/model/RelayAuthPermissionLedger.kt` | +| Policy enum `ALWAYS`/`NEVER`/`IF_IN_MY_LIST`, decision enum `ALLOW`/`DENY` | `commons/.../relayauth/RelayAuthPolicy.kt` | +| Per-relay override persistence interface + DataStore impl | `commons/.../relayauth/RelayAuthPermissionStore.kt`, `amethyst/.../authCommand/model/DataStoreRelayAuthPermissionStore.kt` | +| Settings screen (global policy + per-relay list) | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` | +| Global policy setting, persisted local-only | `AccountSettings.defaultRelayAuthPolicy`, `LocalPreferences` key `DEFAULT_RELAY_AUTH_POLICY` | +| Blocked-relay list (kind 10006) | `amethyst/.../model/nip51Lists/blockedRelays/BlockedRelayListState.kt` (`.flow`) | +| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `FollowListsState.isUserInFollowSets(...)` | +| DM / NIP-65 relay lookups | `DmRelayListState`, `Nip65RelayListState` (+ per-user via `LocalCache`) | + +## The gap + +`RelayAuthPermissionLedger.decide(relayUrl)` receives **only a relay URL**. It +resolves ALLOW/DENY **silently and immediately**. Three things are missing: + +1. **No purpose/"why".** The decision point can't tell a DM-send from a + feed-read from a stranger's random challenge, so it can't explain itself or + attribute the relay to a counterparty. +2. **No interactive ASK.** `RelayAuthDecision` is binary. A DENY silently drops + the auth (and the send fails with no feedback). +3. **No follow-based trust.** `IF_IN_MY_LIST` only checks *my own* relays, never + "this relay belongs to someone I follow." + +## Recommended architecture + +Four changes, smallest surface first. + +### 1. Carry the *purpose* to the decision point — `AuthPurpose` + an intent registry + +New (in `commons/.../relayauth/`, KMP-safe, no Android deps): + +```kotlin +sealed interface AuthPurpose { + data class SendDM(val recipients: Set) : AuthPurpose // recipient DM inboxes (10050) + data class NotifyInbox(val recipients: Set) : AuthPurpose // recipient NIP-65 read relays + data class ReadOutbox(val author: HexKey?) : AuthPurpose // author write/outbox relays + data object MyOwnRelay : AuthPurpose // relay in my own lists + data object Unknown : AuthPurpose // bare challenge, no attribution +} +``` + +The auth path is **reactive** (relay pushes the challenge; the lambda only knows +the URL). Most intent is already recoverable from quartz's per-relay pending +events + active filters (see "Where it lives" below), so the registry below is +**minimal** — only for hints quartz can't infer (e.g. the human recipient behind +an encrypted gift wrap). It lives with the coordinator, since `LocalCache`/ +`Account` are main-process only: + +```kotlin +// amethyst/.../service/relayClient/authCommand/model/RelayAuthIntentRegistry.kt +class RelayAuthIntentRegistry { + fun register(relay: NormalizedRelayUrl, purpose: AuthPurpose) // short TTL entry + fun purposesFor(relay: NormalizedRelayUrl): List // read at decision time +} +``` + +Representative registration sites (each already computes its target relays): +- NIP-17 DM send → `SendDM(recipients)` on each recipient DM-inbox relay. +- Reply/mention/reaction broadcast → `NotifyInbox(recipients)`. +- Outbox feed subscriptions → `ReadOutbox(author)`. + +*Race note:* keying by relay URL means concurrent purposes can collide; store a +small time-bounded **set** per relay and let the resolver consider all live +entries (the prompt can say "to send your DM to Alice and 2 others"). Acceptable +for a UX hint + trust check; the persisted decision is what actually gates. + +### 1b. Persist *why* each relay was granted (grant rationale) + +The decision stays **relay-based**, but each relay's stored record must also +remember **why** it was granted, so the settings screen can show, per relay, +purpose-grouped lines of counterparty users (with avatars): + +> **wss://inbox.example.com** — Allowed +> · To send DMs to: (avatars) Alice, Bob, Carol +> · To download posts from: (avatars) Dave, Erin + +Extend the persisted per-relay record from a bare `RelayAuthDecision` to +`decision + rationale`, where the rationale is an accumulated map keyed by +purpose kind: + +```kotlin +// commons/.../relayauth/RelayAuthGrant.kt (new) +data class RelayAuthGrant( + val decision: RelayAuthDecision, + // purpose kind -> counterparty pubkeys seen for this relay under that purpose + val rationale: Map> = emptyMap(), + val lastUsedAt: Long = 0L, +) +enum class AuthPurposeKind { SEND_DM, NOTIFY_INBOX, READ_OUTBOX, MY_OWN_RELAY } +``` + +The rationale is **updated every time** an auth is granted/re-used for that +relay: merge the current `AuthPurpose` counterparties into the matching kind's +set and refresh `lastUsedAt`. This keeps the "why" current as new +DMs/notifications/feeds route through the relay. Store only pubkeys — names and +avatars are resolved for display from `LocalCache` at render time, so the store +stays privacy-light and small. + +### 2. Add an `ASK` outcome and a context-aware resolver + +Extend the decision enum and generalize `decide()`: + +```kotlin +enum class RelayAuthDecision { ALLOW, DENY, ASK } // ASK added + +class RelayAuthContext(val relayUrl: String, val purposes: List) +``` + +`RelayAuthPermissionLedger.decide(ctx)` precedence (highest → lowest): + +1. **Blocked-relay list** (kind 10006) → `DENY`. Never reveal identity to a + blocked relay, whatever the policy. +2. **Explicit per-relay override** (`RelayAuthPermissionStore`) → return it. +3. **Global policy**: + - `NEVER` → `DENY` + - `ALWAYS` → `ALLOW` + - `IF_IN_MY_LIST` → `ALLOW` if relay ∈ my relay lists, else fall through + - `TRUSTED_FOLLOWS` *(new — see idea A below)* → `ALLOW` if relay ∈ my lists + **or** any counterparty in `ctx.purposes` is followed (`Account.allFollows` + / `FollowListsState.isUserInFollowSets`) and the purpose permits it; else + fall through. +4. **Fall-through**: `ASK` if the purpose is attributable (we can show a reason); + otherwise `DENY` silently (don't prompt for anonymous stranger challenges). + +Keep the current relay-only `decide(url)` as a thin overload calling +`decide(RelayAuthContext(url, registry.purposesFor(url)))` so existing callers +compile. + +Whenever the resolver yields `ALLOW` and an auth is actually sent — regardless +of *how* it was allowed (auto policy, stored override, or a just-approved ASK) — +call `store.recordUse(relayUrl, purpose)` for each attributed purpose so the +grant rationale (§1b) stays current. + +### 3. Surface the ASK prompt to the UI and await the answer + +The `signWithAllLoggedInUsers` lambda in `AuthCoordinator` is **already a +`suspend` context**, so the resolver can suspend and await a user decision — no +restructuring of the auth send path. + +- Add an event stream on the coordinator (or account): + `SharedFlow` where + `RelayAuthRequest(relay, purposes, reply: CompletableDeferred)`. + (Follows the repo's one-shot-event flow pattern — see `kotlin-flow-state-event-modeling`.) +- A composable observer (registered in the logged-in scaffold) collects the flow + and shows a dialog: *"{relay} requires you to log in to {reason}."* with + actions **Allow once / Always allow this relay / Block this relay**. The last + two write through `RelayAuthPermissionLedger.setDecision(...)`. +- The lambda `await`s the deferred (bounded by a timeout consistent with + `RelayAuthStatus`), then proceeds to sign or returns `emptyList()`. + +Reason strings are derived from `AuthPurpose` via a small mapper (resolve +recipient pubkeys → display names through `LocalCache`). + +### 4. New policy mode + settings + +- Add `TRUSTED_FOLLOWS` to `RelayAuthPolicy` (recommended — idea A). +- `RelayAuthSettingsScreen`: add the new mode with an explanatory blurb; the + per-relay override list already supports force-allow/force-block (now + three-state incl. "ask"). No storage-format change if we keep decisions + per-relay (idea B, recommended default). + +## Where it lives: quartz (generic mechanism) vs amethyst (policy + UI) + +Goal (per the brief): if the auth+resend mechanism can be made **robust and +generic**, it belongs in **quartz**; only the *semantics* (why / follow-trust / +prompt copy / rationale UI) stay in **amethyst**. + +### The resend queue already exists in quartz — and is the "intent registry" + +`PoolEventOutbox` / `PoolEventOutboxState` already persist outgoing events +per-relay across reconnects, and `NostrClient.syncFilters(relay)` — called on +connect **and after an auth OK** (`RelayAuthenticator.checkAuthResults`) — +already re-sends pending EVENTs, not just REQ subscriptions. So the park-and- +flush half of idea C is largely built; we just need to make it correct. + +It also means we mostly **don't need a separate `RelayAuthIntentRegistry`**: +quartz already knows, per relay, the *pending outgoing events* +(`PoolEventOutbox`) and the *active subscription filters* (`activeRequests`). +That set IS the intent. At AUTH time quartz can hand the injected decision +callback this context; amethyst derives purpose from it (a pending kind-1059 +gift wrap → `SendDM`; a REQ whose `authors` are followed → `ReadOutbox`). Keep a +tiny registry only for hints quartz can't infer (e.g. the human recipient behind +a gift wrap, which is encrypted) — but drive the common cases off quartz state. + +### Generic fixes to land in quartz (`nip01Core/relay/client/`) + +1. **Treat `auth-required` as a first-class deferred state, not a burned retry.** + *(Landed — commit 2.)* The resend-after-auth path already works: + `syncFilters` on the auth `OK` re-sends every still-pending EVENT, so the + common single-round case (send → `auth-required` → auth → resend → accepted) + already delivered. The narrow bug: `PoolEventOutboxState.newResponse` sent + `auth-required` down the generic-failure path, so each NAK consumed the + per-relay retry budget (`isDone() = responses.size > 2 || tries.size > 3`). + Budget exhaustion isn't checked on the NAK itself but on the **next + `newTry`** — i.e. the resend `syncFilters` issues after the auth `OK`. So + across *repeated* rounds (slow external NIP-55 signer, reconnect churn, or a + relay that re-challenges) the saved event could be **evicted right as it was + about to be redelivered**. Fix: `auth-required` records no failure and leaves + `relaysRemaining` untouched (mirrors `StandaloneRelayClient`'s + `!msg.message.startsWith("auth-required")`), so the existing resend can + redeliver no matter how many auth rounds elapse first. +2. **Real retry policy instead of a hard count.** Replace the `>2 / >3` cliff + with bounded retries + backoff, and a **terminal "gave up" notification** + (via `RelayConnectionListener` / a publish-result callback) so events are + never *silently* dropped. `NostrClientPublishExt.publishAndConfirmDetailed` + and `pendingPublishRelaysFor` already give higher layers a confirmation + surface to build on. +3. **Enrich the injected auth-decision callback with pending context.** The + `signWithAllLoggedInUsers = (relayUrl, authTemplate) -> …` hook in + `RelayAuthenticator` currently gets only the URL. Pass a generic + `RelayAuthChallengeContext` carrying the relay's pending events + active + filters, and let it return not just "sign or not" but an outcome that can + **suspend for a host decision**. The `AuthPurpose`/`RelayAuthContext` types + move to a quartz-neutral shape (opaque to quartz); amethyst supplies the + resolver. +4. **Expose an "event is blocked on auth for relay X" signal** so a host UI can + show the prompt and reflect "queued, not lost." A `SharedFlow`/listener on the + client, host-agnostic. + +### What stays in amethyst + +The *policy and meaning*: blocked-list + follow-graph resolver, purpose/ +counterparty derivation (needs `LocalCache`/`Account`, main-process only), the +`TRUSTED_FOLLOWS` mode, the ASK prompt UI, and the per-relay **grant rationale** +persistence + settings rows (§1b). These depend on identity/UI and cannot live +in quartz. + +## A few ideas / open decisions + +These are the knobs where more than one answer is defensible. Recommendation +first. + +- **A. Follow-based trust shape.** *(Recommended: new `TRUSTED_FOLLOWS` policy + mode.)* Cleanest extension of the existing enum + settings radio group. + Alternatives: a separate independent "trust relays of people I follow" toggle + that layers on any base mode (more flexible, more UI); or never-automatic — + follow-status only pre-selects the "remember" button in the ASK dialog (most + conservative). + +- **B. Decision memory granularity.** *(Decided: per-relay — the decision gate + is one ALLOW/DENY per relay.)* We keep the gate relay-based but enrich the + stored record with the grant rationale (§1b) so the settings screen can + explain each relay. Rejected alternative: making the *gate itself* + per-purpose × per-relay (allow relay X for DMs but keep asking for feed reads) + — richer but more confusing; the rationale display gives the transparency + without splitting the gate. + +- **C. In-flight send when auth isn't yet granted.** *(Recommended: fix + quartz's existing outbox so park-and-flush is the default.)* The queue already + exists (`PoolEventOutbox` + `syncFilters`-after-auth); the work is making + `auth-required` a deferred state (not a burned retry) and adding backoff + a + terminal give-up signal — see the quartz section above. This is strictly + better than the amethyst-only best-effort/retry fallback and is generic, so it + belongs in quartz. Best-effort remains the trivial fallback only if we choose + not to touch quartz. + +- **D. Which purposes auto-trust covers.** DMs and public inbox notifications are + clear yes. Outbox/feed reads ("maybe" in the brief) could be a sub-toggle + under `TRUSTED_FOLLOWS` so reading is treated more liberally than writing. + +## Files to touch + +- `commons/.../relayauth/RelayAuthPolicy.kt` — add `TRUSTED_FOLLOWS`, add `ASK`. +- `commons/.../relayauth/AuthPurpose.kt` — **new** sealed hierarchy + `RelayAuthContext` + `AuthPurposeKind`. +- `commons/.../relayauth/RelayAuthGrant.kt` — **new** per-relay record (decision + rationale, §1b). +- `commons/.../relayauth/RelayAuthPermissionStore.kt` + `amethyst/.../DataStoreRelayAuthPermissionStore.kt` + — store/load `RelayAuthGrant` (decision + rationale) instead of a bare decision; add a + `recordUse(relayUrl, purpose)` merge that updates the rationale + `lastUsedAt`. +- `amethyst/.../authCommand/model/RelayAuthPermissionLedger.kt` — context-aware + `decide(ctx)`, blocked-list + follow-trust inputs, `ASK` fall-through. +- `amethyst/.../authCommand/model/RelayAuthIntentRegistry.kt` — **new, minimal**: + only for hints quartz can't infer (e.g. the recipient behind an encrypted gift + wrap). Common purposes are derived from quartz's pending events + active + filters instead. + +**Quartz (generic mechanism — see the quartz section):** +- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt` + + `PoolEventOutbox.kt` — `auth-required` as a pending-auth state excluded from + `isDone()`; bounded retry + backoff; terminal give-up notification. +- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — pass a + `RelayAuthChallengeContext` (pending events + active filters) to the injected + decision hook; allow the hook to suspend for a host decision. +- `quartz/.../nip01Core/relay/client/listeners/RelayConnectionListener.kt` (or a + new client `SharedFlow`) — "event blocked on auth for relay X" + "gave up" + signals. Fold the good `StandaloneRelayClient` auth-retry logic into the + production path. +- `amethyst/.../authCommand/model/AuthCoordinator.kt` — build `RelayAuthContext` + from the registry, emit `RelayAuthRequest` on `ASK`, await the reply. +- Wire the ledger's new inputs where it's constructed (blocked-list flow, + follow-check, relay-ownership lookups from `DmRelayListState`/`Nip65RelayListState`). +- Registration calls at the DM sender, reply/reaction broadcaster, and outbox + feed subscription. +- `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` — new + mode; three-state per-relay overrides; **per-relay rationale rows** grouped by + purpose ("To send DMs to: …", "To download posts from: …") rendering + counterparty avatars + names resolved from `LocalCache`. +- New composable dialog + observer for `RelayAuthRequest`, hosted in the + logged-in scaffold. + +## Verification + +- **Unit (commons/amethyst JVM):** table-test `decide(ctx)` across the + precedence ladder — blocked beats override beats policy; `TRUSTED_FOLLOWS` + allows a followed-counterparty relay and falls to `ASK` for a stranger; + `Unknown` purpose → silent `DENY`. +- **Quartz outbox (JVM unit tests):** an `auth-required` NAK does **not** advance + `isDone()` and the event survives; after a simulated auth OK, `syncFilters` + re-sends it; a non-auth terminal error still discards; retries honor backoff + and emit a give-up signal instead of a silent drop. Include a race test: + repeated `auth-required` NAKs before auth completes must not drop the event. +- **Intent registry:** register/expire, multi-purpose merge on one relay. +- **Grant rationale:** `recordUse` merges new counterparties into the right + purpose kind, dedupes, refreshes `lastUsedAt`; `allDecisions()`/settings query + returns the grouped rationale for rendering. +- **`amy` interop:** drive a NIP-17 send to a recipient whose 10050 relay + requires auth against a local auth-required relay (`amy serve` / geode) and + confirm the AUTH round-trip + delivery once allowed. (Enforces the + verify-don't-guess rule.) +- **Manual:** send a DM to a followed vs non-followed npub on an auth-required + inbox under each policy mode; confirm the prompt copy names the right reason + and that Always/Block persist. +- `./gradlew :commons:test :amethyst:testDebugUnitTest` and `./gradlew spotlessApply`. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b1e132d3e5..aa759eed27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -162,6 +162,10 @@ private object PrefKeys { const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode" + const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues" + const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows" + const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows" + const val RELAY_AUTH_TRUST_MESSAGE_STRANGERS = "relay_auth_trust_message_strangers" const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled" const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications" @@ -517,6 +521,10 @@ object LocalPreferences { putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value) putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value) // Any account that reaches a save has its notification filter in its @@ -636,8 +644,12 @@ object LocalPreferences { val defaultRelayAuthPolicy = getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null) ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } - ?: RelayAuthPolicy.IF_IN_MY_LIST + ?: RelayAuthPolicy.CUSTOM val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)) + val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true) + val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true) + val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true) + val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false) val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() @@ -847,6 +859,10 @@ object LocalPreferences { alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), relayGroupViewMode = MutableStateFlow(relayGroupViewMode), + relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays), + relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows), + relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows), + relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers), splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled), showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications), backupUserMetadata = latestUserMetadataResolved, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 002cd04e70..87f1363a3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -273,8 +273,13 @@ class AccountSettings( var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, val callsEnabled: MutableStateFlow = MutableStateFlow(true), - val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), + val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.CUSTOM), val relayGroupViewMode: MutableStateFlow = MutableStateFlow(RelayGroupViewMode.DEFAULT), + // The per-situation toggles applied under RelayAuthPolicy.CUSTOM. + val relayAuthTrustMyRelaysAndVenues: MutableStateFlow = MutableStateFlow(true), + val relayAuthTrustReadFollows: MutableStateFlow = MutableStateFlow(true), + val relayAuthTrustMessageFollows: MutableStateFlow = MutableStateFlow(true), + val relayAuthTrustMessageStrangers: MutableStateFlow = MutableStateFlow(false), ) : EphemeralChatRepository, RelayGroupRepository, PublicChatListRepository { @@ -1524,6 +1529,24 @@ class AccountSettings( saveAccountSettings() } } + + private fun changeToggle( + flow: MutableStateFlow, + enabled: Boolean, + ) { + if (flow.value != enabled) { + flow.tryEmit(enabled) + saveAccountSettings() + } + } + + fun changeRelayAuthTrustMyRelaysAndVenues(enabled: Boolean) = changeToggle(relayAuthTrustMyRelaysAndVenues, enabled) + + fun changeRelayAuthTrustReadFollows(enabled: Boolean) = changeToggle(relayAuthTrustReadFollows, enabled) + + fun changeRelayAuthTrustMessageFollows(enabled: Boolean) = changeToggle(relayAuthTrustMessageFollows, enabled) + + fun changeRelayAuthTrustMessageStrangers(enabled: Boolean) = changeToggle(relayAuthTrustMessageStrangers, enabled) } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index f17945b416..c8a18ce7ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -24,12 +24,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */ +private fun venueOwnerPubkey(venueId: String): String? = Address.parse(venueId)?.pubKeyHex + @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -50,10 +55,31 @@ fun RelayAuthSubscription( RelayAuthPermissionLedger( store = Amethyst.instance.relayAuthPermissionStore, globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, + customToggles = { + RelayAuthCustomToggles( + myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value, + readFollows = account.settings.relayAuthTrustReadFollows.value, + messageFollows = account.settings.relayAuthTrustMessageFollows.value, + messageStrangers = account.settings.relayAuthTrustMessageStrangers.value, + ) + }, isInMyRelayList = { relayUrl -> val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized !in account.blockedRelayList.flow.value && - normalized in account.trustedRelays.flow.value + normalized in account.trustedRelays.flow.value + }, + isBlocked = { relayUrl -> + val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false + normalized in account.blockedRelayList.flow.value + }, + // Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty + // enough to reveal our identity to a relay that serves them. + isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors }, + // A venue (public chat / community / live stream) is trusted if we've joined it, or + // its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow. + isTrustedVenue = { venueId -> + venueId in account.publicChatList.flowSet.value || + venueId in account.communityList.flowSet.value || + venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthComposeHelpers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthComposeHelpers.kt new file mode 100644 index 0000000000..33f641ed21 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthComposeHelpers.kt @@ -0,0 +1,63 @@ +/* + * 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.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Loads [pubkey] from the local cache, get-or-creating (and subscribing) if absent, then hands the + * [User] (or null while it loads) to [content]. Shared by the auth prompt dialog and the relay-auth + * settings screen, which both render a person by pubkey while their metadata streams in. + */ +@Composable +internal fun LoadRelayAuthUser( + pubkey: HexKey, + accountViewModel: AccountViewModel, + content: @Composable (User?) -> Unit, +) { + var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) } + if (user == null) { + LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) } + } + content(user) +} + +/** The string explaining a single [AuthPurposeKind] ("To send DMs to", "To download posts from", …). */ +internal fun relayAuthReasonRes(kind: AuthPurposeKind): Int = + when (kind) { + AuthPurposeKind.SEND_DM -> R.string.relay_auth_reason_send_dm + AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_reason_notify_inbox + AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_reason_read_outbox + AuthPurposeKind.POST_VENUE -> R.string.relay_auth_reason_post_venue + AuthPurposeKind.READ_VENUE -> R.string.relay_auth_reason_read_venue + AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_reason_my_own_relay + AuthPurposeKind.OTHER -> R.string.relay_auth_reason_other + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt new file mode 100644 index 0000000000..0208cfa0bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt @@ -0,0 +1,371 @@ +/* + * 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.compose + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPrompt +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.UserAuthChoice +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** Above this many counterparties for a purpose, collapse the named rows into an avatar facepile. */ +private const val NAMED_ROWS_MAX = 2 + +/** Avatars shown in the facepile before the "+N" overflow badge. */ +private const val FACEPILE_MAX = 5 + +/** + * App-wide host for NIP-42 auth prompts. Collects [RelayAuthPromptBus.prompts] and shows one + * dialog at a time explaining *why* a relay wants the user to log in (who it serves), letting the + * user allow once, always allow, or block the relay. Dismissing answers [UserAuthChoice.DISMISS], + * which the bus also falls back to on timeout, so a relay connection never blocks on the UI. + */ +@Composable +fun RelayAuthPromptHost(accountViewModel: AccountViewModel) { + val bus = remember { Amethyst.instance.authCoordinator.promptBus } + val queue = remember { mutableStateListOf() } + + LaunchedEffect(bus) { + bus.prompts.collect { prompt -> + queue.add(prompt) + // Drop the prompt whenever it resolves by any path (answered here, answered on another + // account's dialog, or timed out in the bus) so we never show a stale one. + prompt.onResolved { queue.remove(prompt) } + } + } + + queue.firstOrNull { !it.isResolved }?.let { prompt -> + RelayAuthPromptDialog(prompt, accountViewModel) { choice -> + prompt.respond(choice) + queue.remove(prompt) + } + } +} + +@Composable +private fun RelayAuthPromptDialog( + prompt: RelayAuthPrompt, + accountViewModel: AccountViewModel, + onChoice: (UserAuthChoice) -> Unit, +) { + // The action the user was actually doing drives the title and the "if you don't" consequence, + // so the out-of-context prompt reconnects to their intent. + val primary = remember(prompt) { prompt.purposes.primaryNamed() } + val who = + primary?.let { p -> + when (p.kind) { + AuthPurposeKind.POST_VENUE, AuthPurposeKind.READ_VENUE -> + p.venues.firstOrNull()?.let { rememberVenueLabel(it, accountViewModel) } + else -> counterpartyLabel(p.counterparties, accountViewModel) + } + } + val showLabels = prompt.purposes.size > 1 + + AlertDialog( + onDismissRequest = { onChoice(UserAuthChoice.DISMISS) }, + icon = { + Icon( + symbol = MaterialSymbols.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { Text(titleFor(primary?.kind, who)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringRes(R.string.relay_auth_prompt_message)) + RelayChip(prompt.relayUrl.url) + + prompt.purposes.forEach { purpose -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (showLabels) { + Text( + text = stringRes(relayAuthReasonRes(purpose.kind)), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val people = purpose.counterparties.toList() + if (people.size <= NAMED_ROWS_MAX) { + people.forEach { CounterpartyRow(it, accountViewModel) } + } else { + CounterpartyFacepile(people, accountViewModel) + } + } + } + + consequenceFor(primary?.kind, who)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Button( + onClick = { onChoice(UserAuthChoice.ALLOW_ONCE) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringRes(R.string.relay_auth_allow_once)) } + // For a DM/notification prompt, offer the broad rule: always log in to deliver my + // messages to whoever I'm talking to, so these prompts stop appearing. Those toggles + // only apply under CUSTOM, so switch to it and turn both message toggles on. + if (primary?.kind == AuthPurposeKind.SEND_DM || primary?.kind == AuthPurposeKind.NOTIFY_INBOX) { + FilledTonalButton( + onClick = { + val settings = accountViewModel.account.settings + settings.changeDefaultRelayAuthPolicy(RelayAuthPolicy.CUSTOM) + settings.changeRelayAuthTrustMessageFollows(true) + settings.changeRelayAuthTrustMessageStrangers(true) + onChoice(UserAuthChoice.ALLOW_ONCE) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringRes(R.string.relay_auth_always_deliver)) } + } + FilledTonalButton( + onClick = { onChoice(UserAuthChoice.ALWAYS_ALLOW) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringRes(R.string.relay_auth_always_allow)) } + TextButton( + onClick = { onChoice(UserAuthChoice.BLOCK) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text(stringRes(R.string.relay_auth_block)) } + } + }, + ) +} + +@Composable +private fun RelayChip(url: String) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + ) { + Text( + text = url, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } +} + +@Composable +private fun CounterpartyRow( + pubkey: HexKey, + accountViewModel: AccountViewModel, +) { + LoadRelayAuthUser(pubkey, accountViewModel) { user -> + if (user != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture(user, 32.dp, accountViewModel) + UsernameDisplay(user, accountViewModel = accountViewModel) + } + } + } +} + +@Composable +private fun CounterpartyFacepile( + pubkeys: List, + accountViewModel: AccountViewModel, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-8).dp), + ) { + pubkeys.take(FACEPILE_MAX).forEach { pubkey -> + LoadRelayAuthUser(pubkey, accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 30.dp, + accountViewModel = accountViewModel, + modifier = Modifier.border(2.dp, MaterialTheme.colorScheme.surface, CircleShape), + ) + } + } + } + val extra = pubkeys.size - FACEPILE_MAX + if (extra > 0) { + Text( + text = "+$extra", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 14.dp), + ) + } + } +} + +/** The purpose that best describes what the user was doing (most user-facing first). */ +private fun List.primaryNamed(): AuthPurpose? = + listOf( + AuthPurposeKind.SEND_DM, + AuthPurposeKind.NOTIFY_INBOX, + AuthPurposeKind.POST_VENUE, + AuthPurposeKind.READ_OUTBOX, + AuthPurposeKind.READ_VENUE, + ).firstNotNullOfOrNull { kind -> + firstOrNull { it.kind == kind && (it.counterparties.isNotEmpty() || it.venues.isNotEmpty()) } + } + +@Composable +private fun titleFor( + kind: AuthPurposeKind?, + who: String?, +): String = + when (kind) { + AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_title_send_dm, who ?: "") + AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_title_notify, who ?: "") + AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_title_read, who ?: "") + AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_title_post_venue, who ?: "") + AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_title_read_venue, who ?: "") + else -> stringRes(R.string.relay_auth_prompt_title) + } + +@Composable +private fun consequenceFor( + kind: AuthPurposeKind?, + who: String?, +): String? = + when (kind) { + AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_consequence_send_dm, who ?: "") + AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_consequence_notify, who ?: "") + AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_consequence_read, who ?: "") + AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_consequence_post_venue, who ?: "") + AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_consequence_read_venue, who ?: "") + else -> null + } + +/** A short label for a set of counterparties: the first person's name, or "Alice and others". */ +@Composable +private fun counterpartyLabel( + pubkeys: Set, + accountViewModel: AccountViewModel, +): String { + val first = pubkeys.firstOrNull() ?: return "" + val name = rememberDisplayName(first, accountViewModel) + return if (pubkeys.size > 1) stringRes(R.string.relay_auth_name_and_others, name) else name +} + +/** + * A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity, + * or a NIP-72 community. For channels/live activities it get-or-creates the channel and observes it, + * subscribing to relays for the title so it loads and updates like a person's name; a community's + * name is its NIP-72 d-identifier, taken straight from the address. + */ +@Composable +private fun rememberVenueLabel( + venueId: String, + accountViewModel: AccountViewModel, +): String { + val channel: Channel? = + remember(venueId) { + when { + venueId.length == 64 -> accountViewModel.checkGetOrCreatePublicChatChannel(venueId) + venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) } + else -> null + } + } + + if (channel != null) { + // Subscribes for the channel's metadata and recomposes when it arrives. + val state by observeChannel(channel, accountViewModel) + val name = (state?.channel ?: channel).toBestDisplayName() + if (name.isNotBlank()) return name + } + + // Community: the d-identifier is the name in NIP-72. Also the fallback for an unresolved channel. + return venueId.substringAfterLast(':').ifEmpty { venueId.take(8) } +} + +/** The best display name for [pubkey], reactive to metadata arriving from relays. */ +@Composable +private fun rememberDisplayName( + pubkey: HexKey, + accountViewModel: AccountViewModel, +): String { + var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) } + if (user == null) { + LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) } + } + val loaded = user ?: return pubkey.take(8) + // Reading the observed metadata registers a snapshot read, so the name updates when it arrives. + val metadata by observeUserInfo(loaded, accountViewModel) + return metadata?.info?.bestName() ?: loaded.toBestDisplayName() +} 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 e8e1252cea..533d7c7c67 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 @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -38,6 +38,7 @@ class ScreenAuthAccount( class AuthCoordinator( client: INostrClient, scope: CoroutineScope, + val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(), ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } private val tempAccount by lazy { @@ -59,22 +60,36 @@ class AuthCoordinator( client, scope, signWithAllLoggedInUsers = { relayUrl, authTemplate -> - val currentLedgers = relayLedgers - val shouldAuth = - if (currentLedgers.isEmpty()) { - true - } else { - var allow = false - for (ledger in currentLedgers) { - if (ledger.decide(relayUrl.url) == RelayAuthDecision.ALLOW) { - allow = true - break - } - } - allow + // Reconstruct *why* this relay wants auth from what we're doing with it, so each + // account's ledger can apply follow-based trust and (later) explain the prompt. + // Built lazily so the no-ledgers auto-allow path below doesn't pay for it. + val context by + lazy(LazyThreadSafetyMode.NONE) { + RelayAuthContext( + relayUrl = relayUrl.url, + purposes = + RelayAuthPurposeDeriver.derive( + pendingEvents = client.activeOutboxEvents(relayUrl), + activeFilters = client.activeRequests(relayUrl), + ), + ) } + val currentLedgers = relayLedgers + // Ask the user (only in the ASK case) and fold every account's verdict into one + // decision plus an optional per-relay override to remember. + val outcome = + AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) { + promptBus.requestDecision(relayUrl, context.purposes) + } + outcome.remember?.let { decision -> + currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision) + } + val shouldAuth = outcome.shouldAuth if (shouldAuth) { + // Remember why we granted this relay so the settings screen can explain it. + currentLedgers.firstOrNull()?.recordGrant(context) + // distinct() returns Set (the key type U of ListWithUniqueSetCache) val results = authWithAccounts.distinct().mapNotNull { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt new file mode 100644 index 0000000000..5223e51add --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt @@ -0,0 +1,68 @@ +/* + * 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.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict + +/** + * Combines every logged-in account's [RelayAuthVerdict] into a single decision for whether to + * authenticate with a relay — and, when the user is asked, what to remember. Pulled out of + * [AuthCoordinator] so the policy is unit-testable without the relay client, signers, or Compose. + */ +object AuthDecisionResolver { + /** + * @param shouldAuth whether to sign the NIP-42 auth challenge. + * @param remember a per-relay override to persist ([RelayAuthDecision.ALLOW]/[RelayAuthDecision.DENY]), + * or null to leave the relay's stored decision untouched. + */ + data class Outcome( + val shouldAuth: Boolean, + val remember: RelayAuthDecision? = null, + ) + + /** + * Resolves the combined verdict: + * - **No verdicts** (no ledgers watching) => auto-authenticate; the caller has no policy to apply. + * - **Any [RelayAuthVerdict.ALLOW]** => authenticate without asking. + * - **Any [RelayAuthVerdict.ASK]** (and none allow) => call [prompt] and act on the user's choice, + * remembering ALLOW/DENY for Always-allow / Block. + * - **Otherwise** (all DENY) => do not authenticate. + * + * [prompt] is only invoked in the ASK case, so the no-op paths never build a dialog. + */ + suspend fun resolve( + verdicts: List, + prompt: suspend () -> UserAuthChoice, + ): Outcome = + when { + verdicts.isEmpty() -> Outcome(shouldAuth = true) + verdicts.any { it == RelayAuthVerdict.ALLOW } -> Outcome(shouldAuth = true) + verdicts.any { it == RelayAuthVerdict.ASK } -> + when (prompt()) { + UserAuthChoice.ALLOW_ONCE -> Outcome(shouldAuth = true) + UserAuthChoice.ALWAYS_ALLOW -> Outcome(shouldAuth = true, remember = RelayAuthDecision.ALLOW) + UserAuthChoice.BLOCK -> Outcome(shouldAuth = false, remember = RelayAuthDecision.DENY) + UserAuthChoice.DISMISS -> Outcome(shouldAuth = false) + } + else -> Outcome(shouldAuth = false) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt index 67e2b22817..abdb5c5e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -22,12 +22,15 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.first import java.io.File import java.security.MessageDigest @@ -64,9 +67,9 @@ class DataStoreRelayAuthPermissionStore( } override suspend fun clearDecision(relayUrl: String) { - store.edit { - it.remove(decisionKey(relayUrl)) - it.remove(urlKey(relayUrl)) + store.edit { prefs -> + prefs.remove(decisionKey(relayUrl)) + pruneUrlIfEmpty(prefs, relayUrl) } } @@ -84,13 +87,129 @@ class DataStoreRelayAuthPermissionStore( return result } + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) { + if (additions.isEmpty()) return + + // Auth is granted again on every reconnect, so avoid a disk write when nothing changed: + // only persist if a purpose gained a counterparty we haven't stored, or the last-used + // timestamp is stale enough to be worth refreshing. + val prefs = store.data.first() + val now = TimeUtils.now() + val lastUsed = prefs[lastUsedKey(relayUrl)]?.toLongOrNull() ?: 0L + val hasNewCounterparty = + additions.any { (kind, pubkeys) -> + val existing = prefs[rationaleKey(relayUrl, kind)].toPubkeySet() + !existing.containsAll(pubkeys) && existing.size < MAX_COUNTERPARTIES_STORED + } + if (!hasNewCounterparty && now - lastUsed < LAST_USED_REFRESH_SECS) return + + store.edit { edit -> + edit[urlKey(relayUrl)] = relayUrl + edit[lastUsedKey(relayUrl)] = now.toString() + for ((kind, pubkeys) in additions) { + if (pubkeys.isEmpty()) continue + val key = rationaleKey(relayUrl, kind) + val existing = edit[key].toPubkeySet() + if (existing.size >= MAX_COUNTERPARTIES_STORED || existing.containsAll(pubkeys)) continue + // Cap the stored set: an outbox relay can serve a large slice of the follow list and + // we only need a representative sample to explain the grant in settings. + edit[key] = (existing + pubkeys).take(MAX_COUNTERPARTIES_STORED).joinToString(SEPARATOR) + } + } + } + + override suspend fun clearRationale(relayUrl: String) { + store.edit { prefs -> + AuthPurposeKind.entries.forEach { prefs.remove(rationaleKey(relayUrl, it)) } + pruneUrlIfEmpty(prefs, relayUrl) + } + } + + override suspend fun allLastUsed(): Map { + val prefs = store.data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(LAST_USED_PREFIX)) continue + val hash = name.removePrefix(LAST_USED_PREFIX) + val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue + val ts = (value as? String)?.toLongOrNull() ?: continue + result[url] = ts + } + return result + } + + /** Removes the shared url + last-used keys once a relay has neither an override nor rationale, + * so a partial clear never orphans the reverse-lookup other queries depend on. */ + private fun pruneUrlIfEmpty( + prefs: MutablePreferences, + relayUrl: String, + ) { + val hasDecision = prefs[decisionKey(relayUrl)] != null + val hasRationale = AuthPurposeKind.entries.any { prefs[rationaleKey(relayUrl, it)] != null } + if (!hasDecision && !hasRationale) { + prefs.remove(urlKey(relayUrl)) + prefs.remove(lastUsedKey(relayUrl)) + } + } + + override suspend fun loadRationale(relayUrl: String): Map> { + val prefs = store.data.first() + return buildMap { + for (kind in AuthPurposeKind.entries) { + val pubkeys = prefs[rationaleKey(relayUrl, kind)].toPubkeySet() + if (pubkeys.isNotEmpty()) put(kind, pubkeys) + } + } + } + + override suspend fun allRationales(): Map>> { + val prefs = store.data.first() + val result = mutableMapOf>>() + for ((key, value) in prefs.asMap()) { + val name = key.name + if (!name.startsWith(RATIONALE_PREFIX)) continue + val rest = name.removePrefix(RATIONALE_PREFIX) // ":" + val hash = rest.substringBefore(':') + val kind = runCatching { AuthPurposeKind.valueOf(rest.substringAfter(':')) }.getOrNull() ?: continue + val url = prefs[stringPreferencesKey("$URL_PREFIX$hash")] ?: continue + val pubkeys = (value as? String).toPubkeySet() + if (pubkeys.isNotEmpty()) { + result.getOrPut(url) { mutableMapOf() }[kind] = pubkeys + } + } + return result + } + + private fun String?.toPubkeySet(): Set = this?.split(SEPARATOR)?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet() + private fun decisionKey(relayUrl: String) = stringPreferencesKey("$DECISION_PREFIX${hash(relayUrl)}") private fun urlKey(relayUrl: String) = stringPreferencesKey("$URL_PREFIX${hash(relayUrl)}") + private fun rationaleKey( + relayUrl: String, + kind: AuthPurposeKind, + ) = stringPreferencesKey("$RATIONALE_PREFIX${hash(relayUrl)}:${kind.name}") + + private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") + companion object { private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" + private const val RATIONALE_PREFIX = "rat:" + private const val LAST_USED_PREFIX = "used:" + private const val SEPARATOR = "," + + /** Cap on counterparties remembered per relay+purpose, so a large outbox author set can't + * bloat the DataStore file or the settings screen. */ + private const val MAX_COUNTERPARTIES_STORED = 64 + + /** Minimum seconds between last-used refreshes when no new counterparty appears. */ + private const val LAST_USED_REFRESH_SECS = 300L private fun hash(relayUrl: String): String { val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray()) 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..1b21c235cc 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 @@ -20,34 +20,83 @@ */ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model +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.RelayAuthInputs import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict /** - * Decides whether Amethyst should authenticate with a given relay (NIP-42). + * Decides whether Amethyst should authenticate with a given relay (NIP-42), for one account. * - * Decision order: - * 1. Per-relay override stored in [store] — always wins. - * 2. [globalPolicy]: - * - [RelayAuthPolicy.ALWAYS] → [RelayAuthDecision.ALLOW] - * - [RelayAuthPolicy.NEVER] → [RelayAuthDecision.DENY] - * - [RelayAuthPolicy.IF_IN_MY_LIST] → [RelayAuthDecision.ALLOW] iff [isInMyRelayList] returns true. + * Precedence (see [RelayAuthResolver]): blocked-relay list → per-relay override → global + * [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the + * [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the + * [RelayAuthContext] into followed vs. stranger. */ class RelayAuthPermissionLedger( val store: RelayAuthPermissionStore, val globalPolicy: () -> RelayAuthPolicy, + val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() }, val isInMyRelayList: (String) -> Boolean = { false }, + val isBlocked: (String) -> Boolean = { false }, + val isFollowed: (String) -> Boolean = { false }, + val isTrustedVenue: (String) -> Boolean = { false }, ) { - /** The authorization verdict for [relayUrl]. */ - suspend fun decide(relayUrl: String): RelayAuthDecision { - store.loadDecision(relayUrl)?.let { return it } - return when (globalPolicy()) { - RelayAuthPolicy.ALWAYS -> RelayAuthDecision.ALLOW - RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY - RelayAuthPolicy.IF_IN_MY_LIST -> - if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY - } + /** The authorization verdict for [ctx], taking the challenge's purpose into account. */ + suspend fun decide(ctx: RelayAuthContext): RelayAuthVerdict { + fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX + val inputs = + RelayAuthInputs( + storedOverride = store.loadDecision(ctx.relayUrl), + isBlocked = isBlocked(ctx.relayUrl), + policy = globalPolicy(), + toggles = customToggles(), + isInMyRelayList = isInMyRelayList(ctx.relayUrl), + servesTrustedVenue = + ctx.purposes.any { p -> + (p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) && + p.venues.any(isTrustedVenue) + }, + // Reading a followed author's outbox. + servesFollowedReadCounterparty = + ctx.purposes.any { p -> + p.kind == AuthPurposeKind.READ_OUTBOX && p.counterparties.any(isFollowed) + }, + // Messaging a followed user's inbox (DM / notification). + servesFollowedWriteCounterparty = + ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any(isFollowed) }, + // Messaging a non-followed user's inbox. + servesStrangerWriteCounterparty = + ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } }, + hasAttributablePurpose = + ctx.purposes.any { + it.kind == AuthPurposeKind.MY_OWN_RELAY || + it.kind == AuthPurposeKind.OTHER || + it.counterparties.isNotEmpty() || + it.venues.isNotEmpty() + }, + ) + return RelayAuthResolver.resolve(inputs) + } + + /** Convenience for callers with no purpose context (e.g. a bare challenge). */ + suspend fun decide(relayUrl: String): RelayAuthVerdict = decide(RelayAuthContext(relayUrl)) + + /** + * Records why [ctx]'s relay was authenticated with, so the settings screen can show the + * counterparties behind each grant. Only purposes that name counterparties are recorded. + */ + suspend fun recordGrant(ctx: RelayAuthContext) { + val additions = + ctx.purposes + .filter { it.counterparties.isNotEmpty() } + .associate { it.kind to it.counterparties } + if (additions.isNotEmpty()) store.recordUse(ctx.relayUrl, additions) } /** Stores a per-relay override for [relayUrl]. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt new file mode 100644 index 0000000000..e09d791f25 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt @@ -0,0 +1,119 @@ +/* + * 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.withTimeoutOrNull + +/** What the user chose when asked whether to authenticate with a relay. */ +enum class UserAuthChoice { + /** Authenticate this one time; keep asking next time. */ + ALLOW_ONCE, + + /** Authenticate now and remember ALLOW for this relay. */ + ALWAYS_ALLOW, + + /** Do not authenticate and remember DENY for this relay. */ + BLOCK, + + /** No decision (dismissed or timed out) — do not authenticate, don't remember. */ + DISMISS, +} + +/** + * A pending "should I authenticate with this relay?" question, surfaced to the UI. The relay + * connection coroutine is suspended on [reply] until the user (or a timeout) answers. + */ +class RelayAuthPrompt( + val relayUrl: NormalizedRelayUrl, + val purposes: List, + private val reply: CompletableDeferred, +) { + fun respond(choice: UserAuthChoice) { + reply.complete(choice) + } + + /** True once answered by the user or resolved by the bus (e.g. timed out). */ + val isResolved: Boolean get() = reply.isCompleted + + /** Runs [block] when this prompt is resolved by any path, so the UI can stop showing it. */ + fun onResolved(block: () -> Unit) { + reply.invokeOnCompletion { block() } + } +} + +/** + * Bridges the background NIP-42 auth path to the UI: when a challenge resolves to ASK, the auth + * coroutine calls [requestDecision] and suspends; a Composable collects [prompts], shows a dialog, + * and calls [RelayAuthPrompt.respond]. Concurrent challenges for the same relay share one prompt so + * the user isn't asked twice, and an unanswered prompt resolves to [UserAuthChoice.DISMISS] after + * [timeoutMs] so a connection never hangs forever waiting on a UI that may not be present. + */ +class RelayAuthPromptBus( + private val timeoutMs: Long = DEFAULT_TIMEOUT_MS, +) { + // replay so a challenge raised *before* the UI host subscribes — cold start, an account switch, + // any moment no RelayAuthPromptHost is collecting — isn't dropped (which would stall the auth + // coroutine the full timeout and then silently DISMISS). The host filters out any already- + // resolved prompt it replays, so re-delivering stale ones is harmless. + private val mutablePrompts = MutableSharedFlow(replay = 32, extraBufferCapacity = 32) + val prompts: SharedFlow = mutablePrompts + + private val inFlight = mutableMapOf>() + + suspend fun requestDecision( + relayUrl: NormalizedRelayUrl, + purposes: List, + ): UserAuthChoice { + // Suspension can't happen inside synchronized, so we only decide ownership under the lock + // and await outside it. The owner is the challenge that first created the prompt; any + // concurrent challenge for the same relay awaits the same answer. + val (deferred, isOwner) = + synchronized(inFlight) { + inFlight[relayUrl]?.let { it to false } + ?: CompletableDeferred().also { inFlight[relayUrl] = it } to true + } + + if (isOwner) mutablePrompts.emit(RelayAuthPrompt(relayUrl, purposes, deferred)) + return try { + awaitOrTimeout(deferred) + } finally { + if (isOwner) synchronized(inFlight) { inFlight.remove(relayUrl) } + } + } + + private suspend fun awaitOrTimeout(deferred: CompletableDeferred): UserAuthChoice { + withTimeoutOrNull(timeoutMs) { deferred.await() }?.let { return it } + // Timed out: resolve the deferred so any UI still showing this prompt can drop it, and so a + // concurrent waiter on the same deferred gets an answer too. complete() is a no-op if a late + // user response already won the race. + deferred.complete(UserAuthChoice.DISMISS) + return deferred.await() + } + + companion object { + const val DEFAULT_TIMEOUT_MS = 60_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt new file mode 100644 index 0000000000..9da3445375 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt @@ -0,0 +1,117 @@ +/* + * 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.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +/** Addressable venue kinds whose home relay may require auth: NIP-72 communities and NIP-53 live + * activities. Their `a` addresses are `kind:ownerPubkey:dTag`. */ +private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEvent.KIND) + +/** + * Infers *why* a relay wants NIP-42 auth from what Amethyst is currently doing with it — the + * events pending delivery and the active subscription filters (both from the [INostrClient]). + * Pure so it can be unit-tested without the relay client. + * + * - a pending gift wrap (kind 1059) => sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]); + * - a pending channel/community/live post => [AuthPurposeKind.POST_VENUE] for that venue; + * - any other pending event with `p` tags => delivering it to those users' inboxes ([AuthPurposeKind.NOTIFY_INBOX]); + * - a filter with `authors` => reading those authors' posts ([AuthPurposeKind.READ_OUTBOX]); + * - a filter with `#e`/`#a` venue tags => reading a venue ([AuthPurposeKind.READ_VENUE]); + * - anything else we're actively doing but can't attribute => [AuthPurposeKind.OTHER], so the relay + * is prompted about instead of silently failing. + */ +object RelayAuthPurposeDeriver { + fun derive( + pendingEvents: List, + activeFilters: Map>, + ): List { + val dmRecipients = mutableSetOf() + val notifyRecipients = mutableSetOf() + val postVenues = mutableSetOf() + var unattributedWrite = false + + pendingEvents.forEach { event -> + val pubkeys = event.tags.mapNotNull(PTag::parseKey) + val venues = event.tags.mapNotNull(ATag::parseAddress).filter { it.kind in VENUE_KINDS } + when { + event.kind == GiftWrapEvent.KIND -> dmRecipients.addAll(pubkeys) + event.kind == ChannelMessageEvent.KIND -> event.tags.channelRootId()?.let(postVenues::add) + venues.isNotEmpty() -> venues.forEach { postVenues.add(it.toValue()) } + pubkeys.isNotEmpty() -> notifyRecipients.addAll(pubkeys - event.pubKey) + else -> unattributedWrite = true + } + } + + val readAuthors = mutableSetOf() + val readVenues = mutableSetOf() + var unattributedRead = false + activeFilters.values.forEach { filters -> + filters.forEach { filter -> + var matched = false + filter.authors?.let { + readAuthors.addAll(it) + matched = true + } + filter.tags?.get("e")?.let { + readVenues.addAll(it) + matched = true + } + filter.tags + ?.get("a") + ?.mapNotNull { Address.parse(it) } + ?.filter { it.kind in VENUE_KINDS } + ?.takeIf { it.isNotEmpty() } + ?.let { + readVenues.addAll(it.map(Address::toValue)) + matched = true + } + if (!matched) unattributedRead = true + } + } + + return buildList { + if (dmRecipients.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.SEND_DM, dmRecipients)) + if (notifyRecipients.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.NOTIFY_INBOX, notifyRecipients)) + if (postVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.POST_VENUE, venues = postVenues)) + if (readAuthors.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_OUTBOX, readAuthors)) + if (readVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = readVenues)) + // Safety net: we're using this relay but couldn't say how — prompt rather than fail silently. + if (isEmpty() && (unattributedWrite || unattributedRead)) add(AuthPurpose(AuthPurposeKind.OTHER)) + } + } + + /** The venue a channel message posts into: the `e` tag marked "root", else the first `e` tag. */ + private fun Array>.channelRootId(): HexKey? = firstNotNullOfOrNull(MarkedETag::parseRoot)?.eventId ?: firstNotNullOfOrNull(ETag::parseId) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index cab1210817..49704a9575 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils +import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthPromptHost import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription @@ -82,6 +83,9 @@ fun LoggedInPage( // Adds this account to the authentication procedures for relays. RelayAuthSubscription(accountViewModel) + // Shows the "log in to this relay?" dialog when a NIP-42 challenge needs the user to decide. + RelayAuthPromptHost(accountViewModel) + // Loads account information + DMs and Notifications from Relays. AccountFilterAssemblerSubscription(accountViewModel) 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..5a244b6b1e 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 @@ -20,24 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relayauth +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -50,25 +55,50 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.LoadRelayAuthUser import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.PolicyCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile +import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** Avatars shown in a relay's facepile before the "+N" overflow badge. */ +private const val FACEPILE_MAX = 3 + @Composable fun RelayAuthSettingsScreen( accountViewModel: AccountViewModel, @@ -82,171 +112,342 @@ fun RelayAuthSettingsScreen( val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() var perRelayOverrides by remember { mutableStateOf>(emptyMap()) } + var rationales by remember { mutableStateOf>>>(emptyMap()) } + var lastUsed by remember { mutableStateOf>(emptyMap()) } var reloadKey by remember { mutableIntStateOf(0) } LaunchedEffect(reloadKey) { - perRelayOverrides = withContext(Dispatchers.IO) { store.allDecisions() } + withContext(Dispatchers.IO) { + perRelayOverrides = store.allDecisions() + rationales = store.allRationales() + lastUsed = store.allLastUsed() + } } Scaffold( topBar = { TopBarWithBackButton(stringResource(R.string.relay_auth_settings_title), nav) }, ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.relay_auth_global_policy), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(4.dp)) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - RelayAuthPolicy.entries.forEach { policy -> - val (titleRes, descRes, symbol) = - when (policy) { - RelayAuthPolicy.ALWAYS -> - Triple( - R.string.relay_auth_policy_always, - R.string.relay_auth_policy_always_desc, - MaterialSymbols.LockOpen, - ) - RelayAuthPolicy.NEVER -> - Triple( - R.string.relay_auth_policy_never, - R.string.relay_auth_policy_never_desc, - MaterialSymbols.Lock, - ) - RelayAuthPolicy.IF_IN_MY_LIST -> - Triple( - R.string.relay_auth_policy_if_in_my_list, - R.string.relay_auth_policy_if_in_my_list_desc, - MaterialSymbols.PrivacyTip, - ) - } - PolicyCard( - selected = globalPolicy == policy, - symbol = symbol, - label = stringResource(titleRes), - description = stringResource(descRes), - onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, - ) - } + // The union of relays we have an override for and relays we've recorded a reason for — so the + // "why we're logged in" info and the override control live together, one row each. + val relayUrls = + remember(perRelayOverrides, rationales, lastUsed) { + (perRelayOverrides.keys + rationales.keys + lastUsed.keys).toSortedSet().toList() } - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - - if (perRelayOverrides.isNotEmpty()) { - Text( - text = stringResource(R.string.relay_auth_per_relay_overrides), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(4.dp)) - - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(4.dp)) { - perRelayOverrides.entries.sortedBy { it.key }.forEachIndexed { index, (url, decision) -> - if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) - PerRelayOverrideRow( - url = url, - decision = decision, - onRemove = { - scope.launch { - ledger.clearDecision(url) - reloadKey++ - } - }, - onToggle = { - scope.launch { - val next = - if (decision == RelayAuthDecision.ALLOW) { - RelayAuthDecision.DENY - } else { - RelayAuthDecision.ALLOW - } - ledger.setDecision(url, next) - reloadKey++ - } - }, + // LazyColumn so only the visible per-relay rows compose (each builds a NIP-11 icon and + // avatars — real per-row work). Blocks 1 & 2 are small and fixed, so they share one item. + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + // Block 1: when to authenticate (the mode). + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GroupHeader(stringResource(R.string.relay_auth_global_policy)) + RelayAuthPolicy.entries.forEach { policy -> + val (titleRes, descRes, symbol) = + when (policy) { + RelayAuthPolicy.ALWAYS -> + Triple(R.string.relay_auth_policy_always, R.string.relay_auth_policy_always_desc, MaterialSymbols.LockOpen) + RelayAuthPolicy.NEVER -> + Triple(R.string.relay_auth_policy_never, R.string.relay_auth_policy_never_desc, MaterialSymbols.Lock) + RelayAuthPolicy.CUSTOM -> + Triple(R.string.relay_auth_policy_custom, R.string.relay_auth_policy_custom_desc, MaterialSymbols.Tune) + } + PolicyCard( + selected = globalPolicy == policy, + symbol = symbol, + label = stringResource(titleRes), + description = stringResource(descRes), + onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, ) } } + + // Block 2: what to log in to (the custom toggles), as settings switch tiles. + if (globalPolicy == RelayAuthPolicy.CUSTOM) { + val myRelays by account.settings.relayAuthTrustMyRelaysAndVenues.collectAsState() + val readFollows by account.settings.relayAuthTrustReadFollows.collectAsState() + val messageFollows by account.settings.relayAuthTrustMessageFollows.collectAsState() + val messageStrangers by account.settings.relayAuthTrustMessageStrangers.collectAsState() + + SettingsSection(R.string.relay_auth_custom_section) { + SettingsSwitchTile( + icon = MaterialSymbols.Dns, + title = R.string.relay_auth_toggle_my_relays, + description = R.string.relay_auth_toggle_my_relays_desc, + checked = myRelays, + onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) }, + ) + SettingsDivider() + SettingsSwitchTile( + icon = MaterialSymbols.Download, + title = R.string.relay_auth_toggle_read_follows, + description = R.string.relay_auth_toggle_read_follows_desc, + checked = readFollows, + onCheckedChange = { account.settings.changeRelayAuthTrustReadFollows(it) }, + ) + SettingsDivider() + SettingsSwitchTile( + icon = MaterialSymbols.Mail, + title = R.string.relay_auth_toggle_message_follows, + description = R.string.relay_auth_toggle_message_follows_desc, + checked = messageFollows, + onCheckedChange = { account.settings.changeRelayAuthTrustMessageFollows(it) }, + ) + SettingsDivider() + SettingsSwitchTile( + icon = MaterialSymbols.Public, + title = R.string.relay_auth_toggle_message_strangers, + description = R.string.relay_auth_toggle_message_strangers_desc, + checked = messageStrangers, + onCheckedChange = { account.settings.changeRelayAuthTrustMessageStrangers(it) }, + ) + } + } + + // Block 3 header — its rows are the lazy items below. + GroupHeader(stringResource(R.string.relay_auth_per_relay_overrides)) + } + Spacer(Modifier.height(8.dp)) + } + + if (relayUrls.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Text( + text = stringResource(R.string.relay_auth_no_overrides), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } } } else { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.relay_auth_no_overrides), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Each row is its own lazy item but shares one rounded-card background: the first/last + // clip the top/bottom corners so the contiguous rows read as a single settings card. + itemsIndexed(relayUrls, key = { _, url -> url }) { index, url -> + Column( + modifier = + Modifier + .clip(sectionCardShape(index, relayUrls.size)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + if (index > 0) SettingsDivider() + RelayRow( + url = url, + decision = perRelayOverrides[url], + servedUsers = + rationales[url] + ?.values + ?.flatten() + ?.distinct() + .orEmpty(), + lastUsedSecs = lastUsed[url], + accountViewModel = accountViewModel, + nav = nav, + onToggle = { + scope.launch { + // null (allowed by policy) or ALLOW -> block; DENY -> allow. + val next = + if (perRelayOverrides[url] == RelayAuthDecision.DENY) { + RelayAuthDecision.ALLOW + } else { + RelayAuthDecision.DENY + } + ledger.setDecision(url, next) + reloadKey++ + } + }, + onForget = { + scope.launch { + // Single "forget" clears both the override and the recorded reason, + // so the relay drops off this list entirely. + ledger.clearDecision(url) + store.clearRationale(url) + reloadKey++ + } + }, + ) + } } } + + item { Spacer(Modifier.height(16.dp)) } } } } +/** Primary-colored section label, matching [SettingsSection]'s header used across settings. */ @Composable -private fun PerRelayOverrideRow( +private fun GroupHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +/** Corner shape for one row of a grouped settings card: round the outer corners of the first and + * last rows only, so contiguous rows read as a single rounded card. */ +private fun sectionCardShape( + index: Int, + count: Int, +): Shape = + when { + count <= 1 -> RoundedCornerShape(20.dp) + index == 0 -> RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) + index == count - 1 -> RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp) + else -> RectangleShape + } + +/** + * One relay's row in the per-relay list: NIP-11 icon + shortened URL (tap the row to open the relay's + * info screen), when it was last used, a facepile of the people it serves, an Allow/Deny chip, and a + * Forget button. [decision] is null when the relay is allowed by policy rather than an explicit + * override; the chip still reads "Allowed" and tapping it records an explicit block. Styled to match + * the app's other relay lists (icon-led rows separated by dividers). + */ +@Composable +private fun RelayRow( url: String, - decision: RelayAuthDecision, - onRemove: () -> Unit, + decision: RelayAuthDecision?, + servedUsers: List, + lastUsedSecs: Long?, + accountViewModel: AccountViewModel, + nav: INav, onToggle: () -> Unit, + onForget: () -> Unit, ) { + val context = LocalContext.current + val relay = remember(url) { url.normalizeRelayUrlOrNull() } + Row( modifier = Modifier + .clickable { nav.nav(Route.RelayInfo(url)) } .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(start = 16.dp, top = 10.dp, end = 4.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + RelayIcon(relay, url, accountViewModel) Column(Modifier.weight(1f)) { Text( - text = url, - style = MaterialTheme.typography.bodyMedium, + text = relay?.displayUrl() ?: url, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) - } - SuggestionChip( - onClick = onToggle, - label = { + if (servedUsers.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + UserFacepile(servedUsers, accountViewModel) + } + if (lastUsedSecs != null && lastUsedSecs > 0L) { Text( - text = - if (decision == RelayAuthDecision.ALLOW) { - stringResource(R.string.relay_auth_decision_allow) - } else { - stringResource(R.string.relay_auth_decision_deny) - }, - style = MaterialTheme.typography.labelSmall, + text = stringResource(R.string.relay_auth_last_used, timeAgo(lastUsedSecs, context, prefix = "")), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), ) - }, - colors = - if (decision == RelayAuthDecision.ALLOW) { - SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } else { - SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - labelColor = MaterialTheme.colorScheme.onErrorContainer, - ) - }, - ) - IconButton(onClick = onRemove) { - Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_remove_override)) + } + } + DecisionChip(decision = decision, onToggle = onToggle) + IconButton(onClick = onForget) { + Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_forget)) + } + } +} + +/** The relay's NIP-11 icon (robohash fallback), matching the other relay lists in the app. */ +@Composable +private fun RelayIcon( + relay: NormalizedRelayUrl?, + url: String, + accountViewModel: AccountViewModel, +) { + val info = if (relay != null) loadRelayInfo(relay).value else null + RobohashFallbackAsyncImage( + robot = info?.id ?: relay?.displayUrl() ?: url, + model = info?.icon, + contentDescription = stringResource(R.string.relay_info, url), + colorFilter = RelayIconFilter, + modifier = MediumRelayIconModifier, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) +} + +/** Allow/deny pill for a relay. Green when allowed (explicitly or by policy), red when blocked. */ +@Composable +private fun DecisionChip( + decision: RelayAuthDecision?, + onToggle: () -> Unit, +) { + val allowed = decision != RelayAuthDecision.DENY + SuggestionChip( + onClick = onToggle, + label = { + Text( + text = stringResource(if (allowed) R.string.relay_auth_decision_allow else R.string.relay_auth_decision_deny), + style = MaterialTheme.typography.labelSmall, + ) + }, + colors = + if (allowed) { + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + labelColor = MaterialTheme.colorScheme.onErrorContainer, + ) + }, + ) +} + +/** Overlapping avatars for the people a relay serves — [FACEPILE_MAX] pictures then a "+N" badge. */ +@Composable +private fun UserFacepile( + pubkeys: List, + accountViewModel: AccountViewModel, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy((-8).dp)) { + pubkeys.take(FACEPILE_MAX).forEach { pubkey -> + LoadRelayAuthUser(pubkey, accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 28.dp, + accountViewModel = accountViewModel, + modifier = Modifier.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape), + ) + } + } + } + } + val extra = pubkeys.size - FACEPILE_MAX + if (extra > 0) { + Text( + text = "+$extra", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f275053e00..9f8db12724 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -818,18 +818,54 @@ Relay Authentication auth authentication relay sign verify nip-42 identity - Global policy + When to authenticate + What to log in to Always authenticate Sign auth challenges for every relay that requests it Never authenticate Ignore auth challenges from all relays - My relays only - Only authenticate with relays in your relay list + Custom + Choose exactly which relays to log in to. You\'ll be asked about anything you haven\'t allowed below. + My relays and venues + Log in to your own relays and to public chats, communities and live streams you\'ve joined or favorited. + Read posts from people I follow + Log in to a follow\'s relays to download their posts. + Message people I follow + Log in to a follow\'s relays to send DMs, replies and notifications. + Message anyone + Log in to strangers\' relays to send DMs, replies and notifications. Off by default; you\'ll be asked each time instead. + Confirm it\'s you to this relay? + This relay wants to confirm it\'s really you first. Its operator will see which account you are. + + Send your message to %1$s? + Notify %1$s? + Load posts from %1$s? + Post to %1$s? + Open %1$s? + + If you don\'t, your message to %1$s won\'t be delivered. + If you don\'t, %1$s won\'t be notified about this. + If you don\'t, you won\'t see posts from %1$s here. + If you don\'t, your message to %1$s won\'t be posted. + If you don\'t, you won\'t see %1$s here. + %1$s and others + Send your private message to: + Notify: + Download posts from: + Post to this room + Open this room + Use this relay + Connect to your own relay + Allow once + Always allow this relay + Always deliver my messages + Block this relay Per-relay overrides - No per-relay overrides — global policy applies everywhere + Forget + Last used %1$s ago + Nothing here yet — your global policy applies to every relay. Allow Deny - Remove override Source: %1$s v%1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt new file mode 100644 index 0000000000..4f92e7450b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt @@ -0,0 +1,117 @@ +/* + * 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.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthDecisionResolverTest { + /** A prompt that must never be called; fails the test if the resolver asks the user. */ + private val neverPrompt: suspend () -> UserAuthChoice = { error("prompt() should not be called") } + + @Test + fun noVerdictsAutoAuthenticatesWithoutPrompting() = + runTest { + val outcome = AuthDecisionResolver.resolve(emptyList(), neverPrompt) + assertTrue(outcome.shouldAuth) + assertNull(outcome.remember) + } + + @Test + fun anyAllowAuthenticatesWithoutPrompting() = + runTest { + val outcome = + AuthDecisionResolver.resolve( + listOf(RelayAuthVerdict.DENY, RelayAuthVerdict.ALLOW, RelayAuthVerdict.ASK), + neverPrompt, + ) + assertTrue(outcome.shouldAuth) + assertNull(outcome.remember) + } + + @Test + fun allDenyDoesNotAuthenticateAndDoesNotPrompt() = + runTest { + val outcome = + AuthDecisionResolver.resolve( + listOf(RelayAuthVerdict.DENY, RelayAuthVerdict.DENY), + neverPrompt, + ) + assertFalse(outcome.shouldAuth) + assertNull(outcome.remember) + } + + @Test + fun askAllowOnceAuthenticatesButRemembersNothing() = + runTest { + val outcome = + AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.ALLOW_ONCE } + assertTrue(outcome.shouldAuth) + assertNull(outcome.remember) + } + + @Test + fun askAlwaysAllowAuthenticatesAndRemembersAllow() = + runTest { + val outcome = + AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.ALWAYS_ALLOW } + assertTrue(outcome.shouldAuth) + assertEquals(RelayAuthDecision.ALLOW, outcome.remember) + } + + @Test + fun askBlockDoesNotAuthenticateAndRemembersDeny() = + runTest { + val outcome = + AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.BLOCK } + assertFalse(outcome.shouldAuth) + assertEquals(RelayAuthDecision.DENY, outcome.remember) + } + + @Test + fun askDismissDoesNotAuthenticateAndRemembersNothing() = + runTest { + val outcome = + AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.DISMISS } + assertFalse(outcome.shouldAuth) + assertNull(outcome.remember) + } + + @Test + fun askIsOnlyReachedWhenNoAccountAllows() = + runTest { + // ALLOW present alongside ASK must short-circuit to auth without prompting. + var prompted = false + val outcome = + AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK, RelayAuthVerdict.ALLOW)) { + prompted = true + UserAuthChoice.BLOCK + } + assertTrue(outcome.shouldAuth) + assertFalse(prompted) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt new file mode 100644 index 0000000000..a67cc661a2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt @@ -0,0 +1,177 @@ +/* + * 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.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Round-trip tests for the DataStore-backed relay-auth permission store. Backed by a real + * PreferenceDataStore on a per-test temp directory (the store takes a plain filesDir), so it runs + * on the JVM without Robolectric. A fresh directory per test dodges DataStore's per-file + * single-instance guard. + */ +class DataStoreRelayAuthPermissionStoreTest { + @get:Rule val tmp = TemporaryFolder() + + private fun newStore() = DataStoreRelayAuthPermissionStore(tmp.newFolder()) + + private val relay = "wss://auth.relay.test" + private val other = "wss://other.relay.test" + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + @Test + fun decisionRoundTrips() = + runBlocking { + val store = newStore() + assertNull(store.loadDecision(relay)) + + store.storeDecision(relay, RelayAuthDecision.ALLOW) + assertEquals(RelayAuthDecision.ALLOW, store.loadDecision(relay)) + + store.storeDecision(relay, RelayAuthDecision.DENY) + assertEquals(RelayAuthDecision.DENY, store.loadDecision(relay)) + } + + @Test + fun allDecisionsReversesTheHashedKeyBackToTheUrl() = + runBlocking { + val store = newStore() + store.storeDecision(relay, RelayAuthDecision.ALLOW) + store.storeDecision(other, RelayAuthDecision.DENY) + + assertEquals( + mapOf(relay to RelayAuthDecision.ALLOW, other to RelayAuthDecision.DENY), + store.allDecisions(), + ) + } + + @Test + fun recordUseMergesCounterpartiesAcrossCallsGroupedByKind() = + runBlocking { + val store = newStore() + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(bob))) + store.recordUse(relay, mapOf(AuthPurposeKind.NOTIFY_INBOX to setOf(carol))) + + assertEquals( + mapOf( + AuthPurposeKind.SEND_DM to setOf(alice, bob), + AuthPurposeKind.NOTIFY_INBOX to setOf(carol), + ), + store.loadRationale(relay), + ) + } + + @Test + fun recordUseAlwaysStoresANewCounterpartyEvenRightAfterAWrite() = + runBlocking { + // The write throttle must never drop a genuinely new grant: a second call with a new + // counterparty, moments after the first, still has to persist it. + val store = newStore() + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(bob))) + + assertEquals(setOf(alice, bob), store.loadRationale(relay)[AuthPurposeKind.SEND_DM]) + } + + @Test + fun recordUseIsIdempotentForTheSameCounterparty() = + runBlocking { + val store = newStore() + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + + assertEquals(setOf(alice), store.loadRationale(relay)[AuthPurposeKind.SEND_DM]) + } + + @Test + fun recordUseCapsTheStoredCounterpartySet() = + runBlocking { + val store = newStore() + val many = (0 until 100).map { "%064x".format(it) }.toSet() + store.recordUse(relay, mapOf(AuthPurposeKind.READ_OUTBOX to many)) + + assertEquals(64, store.loadRationale(relay)[AuthPurposeKind.READ_OUTBOX]?.size) + } + + @Test + fun recordUsePopulatesLastUsed() = + runBlocking { + val store = newStore() + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + + val ts = store.allLastUsed()[relay] + assertTrue("expected a positive last-used timestamp, got $ts", (ts ?: 0L) > 0L) + } + + @Test + fun clearRationaleDropsRationaleAndPrunesTheReverseLookupWhenNoDecisionRemains() = + runBlocking { + val store = newStore() + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + store.clearRationale(relay) + + assertTrue(store.loadRationale(relay).isEmpty()) + // The shared url + last-used keys must be pruned too, or they'd orphan the reverse lookup. + assertFalse(relay in store.allLastUsed()) + assertFalse(relay in store.allRationales()) + } + + @Test + fun clearRationaleKeepsTheReverseLookupWhenADecisionStillRemains() = + runBlocking { + val store = newStore() + store.storeDecision(relay, RelayAuthDecision.ALLOW) + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + store.clearRationale(relay) + + // The decision must survive, and allDecisions still resolves the url (reverse lookup intact). + assertEquals(RelayAuthDecision.ALLOW, store.loadDecision(relay)) + assertEquals(mapOf(relay to RelayAuthDecision.ALLOW), store.allDecisions()) + } + + @Test + fun clearDecisionKeepsRationaleAndItsReverseLookup() = + runBlocking { + val store = newStore() + store.storeDecision(relay, RelayAuthDecision.DENY) + store.recordUse(relay, mapOf(AuthPurposeKind.SEND_DM to setOf(alice))) + store.clearDecision(relay) + + assertNull(store.loadDecision(relay)) + // Rationale (and the url it depends on for allRationales) must survive the decision clear. + assertEquals( + mapOf(AuthPurposeKind.SEND_DM to setOf(alice)), + store.allRationales()[relay], + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt new file mode 100644 index 0000000000..8b24f098cb --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt @@ -0,0 +1,117 @@ +/* + * 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.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** In-memory [RelayAuthPermissionStore] that unions rationale like the DataStore impl does. */ +private class InMemoryStore : RelayAuthPermissionStore { + private val overrides = mutableMapOf() + private val rationale = mutableMapOf>>() + + override suspend fun loadDecision(relayUrl: String) = overrides[relayUrl] + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + overrides[relayUrl] = decision + } + + override suspend fun clearDecision(relayUrl: String) { + overrides.remove(relayUrl) + } + + override suspend fun allDecisions() = overrides.toMap() + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) { + val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() } + for ((kind, pubkeys) in additions) { + forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys) + } + } + + override suspend fun loadRationale(relayUrl: String) = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap() +} + +class RelayAuthGrantRationaleTest { + private val relay = "wss://inbox.relay.test" + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.CUSTOM }) + + @Test + fun recordsCounterpartiesGroupedByPurpose() = + runTest { + val store = InMemoryStore() + ledger(store).recordGrant( + RelayAuthContext( + relay, + listOf( + AuthPurpose(AuthPurposeKind.SEND_DM, setOf(alice)), + AuthPurpose(AuthPurposeKind.READ_OUTBOX, setOf(bob)), + ), + ), + ) + + assertEquals( + mapOf( + AuthPurposeKind.SEND_DM to setOf(alice), + AuthPurposeKind.READ_OUTBOX to setOf(bob), + ), + store.loadRationale(relay), + ) + } + + @Test + fun mergesNewCounterpartiesAcrossGrants() = + runTest { + val store = InMemoryStore() + val ledger = ledger(store) + + ledger.recordGrant(RelayAuthContext(relay, listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(alice))))) + ledger.recordGrant(RelayAuthContext(relay, listOf(AuthPurpose(AuthPurposeKind.SEND_DM, setOf(carol))))) + + assertEquals(mapOf(AuthPurposeKind.SEND_DM to setOf(alice, carol)), store.loadRationale(relay)) + } + + @Test + fun ignoresPurposesWithoutCounterparties() = + runTest { + val store = InMemoryStore() + ledger(store).recordGrant(RelayAuthContext(relay, listOf(AuthPurpose(AuthPurposeKind.MY_OWN_RELAY)))) + + assertEquals(emptyMap>(), store.loadRationale(relay)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt new file mode 100644 index 0000000000..e3e71b44de --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBusTest.kt @@ -0,0 +1,87 @@ +/* + * 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayAuthPromptBusTest { + private val relay = NormalizedRelayUrl("wss://auth.relay.test") + + @Test + fun deliversTheUsersChoiceToTheWaitingCaller() = + runTest { + val bus = RelayAuthPromptBus() + + val collector = async { bus.prompts.first() } + val caller = async { bus.requestDecision(relay, emptyList()) } + + collector.await().respond(UserAuthChoice.ALWAYS_ALLOW) + assertEquals(UserAuthChoice.ALWAYS_ALLOW, caller.await()) + } + + @Test + fun concurrentChallengesForSameRelayShareOnePrompt() = + runTest { + val bus = RelayAuthPromptBus() + + // Capture the single surfaced prompt before the two challenges fire. + val surfaced = async { bus.prompts.first() } + val first = async { bus.requestDecision(relay, emptyList()) } + val second = async { bus.requestDecision(relay, emptyList()) } + + surfaced.await().respond(UserAuthChoice.ALLOW_ONCE) + + // Both waiters get the one answer. If the second had NOT been deduped it would have + // surfaced its own unanswered prompt and timed out to DISMISS — so this proves dedup. + assertEquals(UserAuthChoice.ALLOW_ONCE, first.await()) + assertEquals(UserAuthChoice.ALLOW_ONCE, second.await()) + } + + @Test + fun unansweredPromptTimesOutToDismiss() = + runTest { + val bus = RelayAuthPromptBus(timeoutMs = 1_000L) + + // No one ever responds; the call must not hang, it resolves to DISMISS. + assertEquals(UserAuthChoice.DISMISS, bus.requestDecision(relay, emptyList())) + } + + @Test + fun retainsPromptForALateSubscriberSoItIsNotLost() = + runTest { + val bus = RelayAuthPromptBus() + + // A challenge fires while NO host is collecting (cold start / account switch). + val caller = async { bus.requestDecision(relay, emptyList()) } + runCurrent() // let the emit happen with no subscriber present + + // A host subscribes late; the replayed prompt must still reach it (not be lost, which + // would strand the caller until the timeout and then silently DISMISS). + bus.prompts.first().respond(UserAuthChoice.ALLOW_ONCE) + assertEquals(UserAuthChoice.ALLOW_ONCE, caller.await()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt new file mode 100644 index 0000000000..bb798ade54 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt @@ -0,0 +1,143 @@ +/* + * 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.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayAuthPurposeDeriverTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + + private fun event( + kind: Int, + pTags: List = emptyList(), + ) = Event( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + kind = kind, + tags = pTags.map { arrayOf("p", it) }.toTypedArray(), + content = "", + sig = "22".repeat(64), + ) + + @Test + fun giftWrapBecomesSendDmToItsRecipient() { + val purposes = RelayAuthPurposeDeriver.derive(listOf(event(GiftWrapEvent.KIND, listOf(alice))), emptyMap()) + + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.SEND_DM, purposes[0].kind) + assertEquals(setOf(alice), purposes[0].counterparties) + } + + @Test + fun nonGiftWrapWithPTagsBecomesNotifyInbox() { + val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1, listOf(alice, bob))), emptyMap()) + + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.NOTIFY_INBOX, purposes[0].kind) + assertEquals(setOf(alice, bob), purposes[0].counterparties) + } + + @Test + fun notifyExcludesTheEventsOwnAuthor() { + val author = "11".repeat(32) // matches event()'s pubKey + val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1, listOf(author, alice))), emptyMap()) + + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.NOTIFY_INBOX, purposes[0].kind) + assertEquals(setOf(alice), purposes[0].counterparties) + } + + @Test + fun subscriptionAuthorsBecomeReadOutbox() { + val purposes = RelayAuthPurposeDeriver.derive(emptyList(), mapOf("sub1" to listOf(Filter(authors = listOf(alice, bob))))) + + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.READ_OUTBOX, purposes[0].kind) + assertEquals(setOf(alice, bob), purposes[0].counterparties) + } + + @Test + fun mixedPendingWorkYieldsAllPurposes() { + val purposes = + RelayAuthPurposeDeriver.derive( + pendingEvents = listOf(event(GiftWrapEvent.KIND, listOf(alice)), event(1, listOf(bob))), + activeFilters = mapOf("sub1" to listOf(Filter(authors = listOf(alice)))), + ) + + assertEquals( + setOf(AuthPurposeKind.SEND_DM, AuthPurposeKind.NOTIFY_INBOX, AuthPurposeKind.READ_OUTBOX), + purposes.map { it.kind }.toSet(), + ) + } + + @Test + fun noActivityYieldsNoPurposes() { + assertEquals(emptyList(), RelayAuthPurposeDeriver.derive(emptyList(), emptyMap())) + } + + @Test + fun unattributableActivityYieldsOtherAsSafetyNet() { + // An event we can't attribute (no p tags, not a venue) still prompts rather than fail silently. + val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1)), emptyMap()) + assertEquals(listOf(AuthPurposeKind.OTHER), purposes.map { it.kind }) + } + + @Test + fun channelMessageBecomesPostVenue() { + val channelId = "e".repeat(64) + val ev = + Event( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + kind = 42, + tags = arrayOf(arrayOf("e", channelId, "", "root")), + content = "hi", + sig = "22".repeat(64), + ) + val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap()) + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.POST_VENUE, purposes[0].kind) + assertEquals(setOf(channelId), purposes[0].venues) + } + + @Test + fun communityAndLiveSubscriptionsBecomeReadVenue() { + val community = "34550:${"1".repeat(64)}:my-community" + val live = "30311:${"2".repeat(64)}:my-stream" + val purposes = + RelayAuthPurposeDeriver.derive( + emptyList(), + mapOf("sub" to listOf(Filter(tags = mapOf("a" to listOf(community, live))))), + ) + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.READ_VENUE, purposes[0].kind) + assertEquals(setOf(community, live), purposes[0].venues) + } +} 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..89988235fc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt @@ -0,0 +1,86 @@ +/* + * 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, + + /** Posting into a venue — a NIP-28 public chat or a NIP-72 community — hosted on this relay. */ + POST_VENUE, + + /** Reading a venue's content (public chat / community) from this relay. */ + READ_VENUE, + + /** The relay is in the user's own relay list. */ + MY_OWN_RELAY, + + /** We're actively using this relay but couldn't attribute a specific purpose (safety net so we + * prompt instead of silently failing). */ + OTHER, +} + +/** + * A single reason a relay connection needs auth. [counterparties] holds the people it concerns + * (pubkeys, for DM/notify/outbox purposes); [venues] holds venue identifiers (channel event-ids or + * community `a`-addresses, for [AuthPurposeKind.POST_VENUE]/[AuthPurposeKind.READ_VENUE]). + */ +data class AuthPurpose( + val kind: AuthPurposeKind, + val counterparties: Set = emptySet(), + val venues: 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/RelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt index 00ee6d66bd..3b33a0666f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt @@ -40,4 +40,27 @@ interface RelayAuthPermissionStore { /** All per-relay overrides — for the relay auth settings screen. */ suspend fun allDecisions(): Map + + /** + * Records *why* [relayUrl] was authenticated with, so the settings screen can explain each + * relay ("To send DMs to: …", "To download posts from: …"). [additions] maps a purpose to the + * counterparty pubkeys seen for it; implementations merge into whatever is already stored. + * Default no-op for stores that don't track rationale. + */ + suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) {} + + /** The accumulated grant rationale for [relayUrl] (purpose → counterparty pubkeys). */ + suspend fun loadRationale(relayUrl: String): Map> = emptyMap() + + /** All per-relay rationales — for the relay auth settings screen. */ + suspend fun allRationales(): Map>> = emptyMap() + + /** Forgets the accumulated grant rationale for [relayUrl] (does not touch the ALLOW/DENY override). */ + suspend fun clearRationale(relayUrl: String) {} + + /** Epoch-second timestamp of the last time each relay was authenticated with (for display). */ + suspend fun allLastUsed(): Map = emptyMap() } 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..041fdb8cf9 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 @@ -21,18 +21,22 @@ package com.vitorpamplona.amethyst.commons.relayauth /** - * The default policy for authenticating with relays (NIP-42). + * The top-level mode for authenticating with relays (NIP-42). * Per-relay overrides stored in [RelayAuthPermissionStore] always take precedence. */ enum class RelayAuthPolicy { - /** Authenticate with every relay that requests it. Equivalent to current behavior. */ + /** Authenticate with every relay that requests it. */ ALWAYS, /** Never authenticate; do not reveal your identity to relay operators via NIP-42. */ NEVER, - /** Authenticate only with relays explicitly listed in the user's relay list. */ - IF_IN_MY_LIST, + /** + * Apply the per-situation [RelayAuthCustomToggles]: authenticate only for the categories the + * user turned on (own relays/venues, reading or messaging follows, messaging strangers). + * Situations no toggle covers fall through to an explicit prompt ([RelayAuthVerdict.ASK]). + */ + CUSTOM, } /** 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..12ef94d17a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt @@ -0,0 +1,114 @@ +/* + * 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 + +/** + * The per-situation switches applied under [RelayAuthPolicy.CUSTOM]. Each independently authorizes + * one category of relay; a situation with no matching toggle falls through to a prompt. + * + * @param myRelaysAndVenues your own relays, plus venues (public chats, communities, live streams) + * you've joined, subscribed to, or favorited. + * @param readFollows a relay serving the outbox of someone you follow (to download their posts). + * @param messageFollows a relay serving the inbox of someone you follow (to send DMs, replies, + * notifications). + * @param messageStrangers a relay serving the inbox of someone you *don't* follow. Off by default — + * sending to a stranger otherwise prompts. + */ +data class RelayAuthCustomToggles( + val myRelaysAndVenues: Boolean = true, + val readFollows: Boolean = true, + val messageFollows: Boolean = true, + val messageStrangers: Boolean = false, +) + +/** + * 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 top-level [RelayAuthPolicy]. + * @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM]. + * @param isInMyRelayList the relay is in the user's own relay list. + * @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the + * user has joined, subscribed to, or favorited. + * @param servesFollowedReadCounterparty a followed user's outbox is served here (reading them). + * @param servesFollowedWriteCounterparty a followed user's inbox is served here (messaging them). + * @param servesStrangerWriteCounterparty a non-followed user's inbox is served here (messaging them). + * @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 toggles: RelayAuthCustomToggles, + val isInMyRelayList: Boolean, + val servesTrustedVenue: Boolean, + val servesFollowedReadCounterparty: Boolean, + val servesFollowedWriteCounterparty: Boolean, + val servesStrangerWriteCounterparty: 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. Top-level [RelayAuthPolicy]: + * - [RelayAuthPolicy.NEVER] → DENY + * - [RelayAuthPolicy.ALWAYS] → ALLOW + * - [RelayAuthPolicy.CUSTOM] → ALLOW if any *enabled* [RelayAuthCustomToggles] category matches + * this relay (own relays/venues, reading follows, messaging follows, messaging strangers); + * 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.CUSTOM -> + if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs) + } + } + + private fun customAllows(inputs: RelayAuthInputs): Boolean { + val t = inputs.toggles + return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) || + (t.readFollows && inputs.servesFollowedReadCounterparty) || + (t.messageFollows && inputs.servesFollowedWriteCounterparty) || + (t.messageStrangers && inputs.servesStrangerWriteCounterparty) + } + + 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..aea8ece986 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt @@ -0,0 +1,133 @@ +/* + * 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.CUSTOM, + toggles: RelayAuthCustomToggles = RelayAuthCustomToggles(), + isInMyRelayList: Boolean = false, + servesTrustedVenue: Boolean = false, + servesFollowedReadCounterparty: Boolean = false, + servesFollowedWriteCounterparty: Boolean = false, + servesStrangerWriteCounterparty: Boolean = false, + hasAttributablePurpose: Boolean = true, + ) = RelayAuthInputs( + storedOverride = storedOverride, + isBlocked = isBlocked, + policy = policy, + toggles = toggles, + isInMyRelayList = isInMyRelayList, + servesTrustedVenue = servesTrustedVenue, + servesFollowedReadCounterparty = servesFollowedReadCounterparty, + servesFollowedWriteCounterparty = servesFollowedWriteCounterparty, + servesStrangerWriteCounterparty = servesStrangerWriteCounterparty, + 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, isInMyRelayList = true))) + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.ALWAYS, hasAttributablePurpose = false))) + } + + @Test + fun customMyRelaysAndVenuesToggleGatesOwnRelaysAndVenues() { + // On (default): my own relay and any joined venue auto-auth. + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(isInMyRelayList = true))) + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true))) + // Off: even my own relay prompts. + val off = RelayAuthCustomToggles(myRelaysAndVenues = false) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(isInMyRelayList = true, toggles = off))) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesTrustedVenue = true, toggles = off))) + } + + @Test + fun customReadFollowsToggleGatesReadingFollows() { + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true))) + assertEquals( + RelayAuthVerdict.ASK, + resolve(inputs(servesFollowedReadCounterparty = true, toggles = RelayAuthCustomToggles(readFollows = false))), + ) + } + + @Test + fun customMessageFollowsToggleGatesMessagingFollows() { + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true))) + assertEquals( + RelayAuthVerdict.ASK, + resolve(inputs(servesFollowedWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageFollows = false))), + ) + } + + @Test + fun customMessageStrangersIsOffByDefault() { + // Default off: messaging a stranger prompts... + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesStrangerWriteCounterparty = true))) + // ...on: auto-auth. + assertEquals( + RelayAuthVerdict.ALLOW, + resolve(inputs(servesStrangerWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageStrangers = true))), + ) + } + + @Test + fun customHasNoToggleForReadingStrangers() { + // Reading a non-followed author (no matching category) always prompts, even with every + // toggle on — there is deliberately no "read strangers" trust category. + val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, hasAttributablePurpose = true))) + } + + @Test + fun customFallsThroughForUncoveredSituation() { + // Nothing matches -> prompt when we know why, else silent deny. + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(hasAttributablePurpose = true))) + assertEquals(RelayAuthVerdict.DENY, resolve(inputs(hasAttributablePurpose = false))) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index a43b3758e6..480ea4fe06 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -127,6 +127,8 @@ class CoordinatorPipelineTest { override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + + override fun activeOutboxEvents(url: NormalizedRelayUrl): List = emptyList() } private fun createCoordinator( diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip42AuthDmDeliveryTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip42AuthDmDeliveryTest.kt new file mode 100644 index 0000000000..b1c1c21e27 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip42AuthDmDeliveryTest.kt @@ -0,0 +1,163 @@ +/* + * 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.geode + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * End-to-end proof that a NIP-17 DM actually delivers to an auth-required relay through the + * [com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutbox] retry queue — the exact + * path Amethyst's auth-permission feature depends on. + * + * The scenario mirrors "send a DM to someone whose inbox relay demands NIP-42 auth": + * 1. The relay runs [FullAuthPolicy], so it rejects every EVENT with `auth-required:` until the + * connection authenticates. + * 2. The client publishes the gift wrap immediately — before AUTH — so the first EVENT is rejected. + * 3. A [RelayAuthenticator] answers the challenge; the relay's OK-true triggers a filter/outbox + * resync and the still-pending wrap is resent and stored. + * + * Because the first EVENT is rejected with `auth-required` (not a hard failure), the outbox must + * NOT burn the wrap's retry budget on it — otherwise the post-AUTH resync would have nothing left + * to resend. This test is the integration counterpart to PoolEventOutboxAuthTest's unit coverage. + */ +class Nip42AuthDmDeliveryTest { + private lateinit var hub: InProcessRelays + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + + private val relayUrl: NormalizedRelayUrl = InProcessRelays.DEFAULT_URL + + @BeforeTest + fun setup() { + // Every connection to this relay demands NIP-42 auth before it will accept an EVENT. + hub = InProcessRelays(defaultPolicy = { FullAuthPolicy(relayUrl) }) + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + /** The recipient's gift wrap (kind 1059) out of a freshly built NIP-17 DM. */ + private suspend fun buildDmWrapTo( + sender: NostrSigner, + recipient: String, + ): GiftWrapEvent { + val template = ChatMessageEvent.build("gm, this is private", listOf(PTag(recipient))) + val result = NIP17Factory().createMessageNIP17(template, sender) + return result.wraps.first { it.recipientPubKey() == recipient } + } + + private suspend fun storedGiftWrapCount(): Int = hub.get(relayUrl)?.store?.count(Filter(kinds = listOf(GiftWrapEvent.KIND))) ?: 0 + + @Test + fun dmDeliversToAuthRequiredRelayAfterAuth() = + runBlocking { + val senderKeys = KeyPair() + val recipient = NostrSignerInternal(KeyPair()).pubKey + val wrap = buildDmWrapTo(NostrSignerInternal(senderKeys), recipient) + + // The signer answers the relay's AUTH challenge on this client's behalf (same identity + // as the DM sender, though FullAuthPolicy would accept any authenticated pubkey). + val authSigner = NostrSignerSync(senderKeys) + val authenticator = + RelayAuthenticator(client = client, scope = scope) { _, template -> + listOf(authSigner.sign(template)) + } + try { + // Publish goes through the outbox and races ahead of AUTH, so the first EVENT is + // rejected `auth-required`; the authenticator then unlocks the relay and the outbox + // resends the still-pending wrap. + client.publish(wrap, setOf(relayUrl)) + + val delivered = + withTimeoutOrNull(10_000) { + while (storedGiftWrapCount() < 1) delay(50) + true + } + + assertEquals(true, delivered, "the DM gift wrap must land after AUTH resolves") + assertEquals( + wrap.id, + hub + .get(relayUrl) + ?.store + ?.query(Filter(kinds = listOf(GiftWrapEvent.KIND))) + ?.single() + ?.id, + "the stored event must be exactly the published wrap", + ) + } finally { + authenticator.destroy() + } + } + + @Test + fun dmIsRejectedByAuthRequiredRelayWithoutAuth() = + runBlocking { + // Control: with no authenticator wired, the relay never unlocks and the wrap is never + // stored — proving the relay genuinely gates on auth (so the test above isn't vacuous). + val recipient = NostrSignerInternal(KeyPair()).pubKey + val wrap = buildDmWrapTo(NostrSignerInternal(KeyPair()), recipient) + + client.publish(wrap, setOf(relayUrl)) + + // Give the client ample time to connect, get rejected, and (wrongly) retry. + delay(2_000) + assertEquals(0, storedGiftWrapCount(), "an unauthenticated EVENT must never be stored") + assertNull( + hub + .get(relayUrl) + ?.store + ?.query(Filter(kinds = listOf(GiftWrapEvent.KIND))) + ?.firstOrNull(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 125643864e..35a09a9748 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -104,6 +104,9 @@ interface INostrClient : AutoCloseable { fun activeCounts(url: NormalizedRelayUrl): Map> fun activeOutboxCache(url: NormalizedRelayUrl): Set + + /** The events still pending delivery to [url] (full events, not just ids). */ + fun activeOutboxEvents(url: NormalizedRelayUrl): List } class EmptyNostrClient : INostrClient { @@ -158,5 +161,7 @@ class EmptyNostrClient : INostrClient { override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + override fun activeOutboxEvents(url: NormalizedRelayUrl): List = emptyList() + override fun close() {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 6516d0cb5b..98d8f6a377 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -303,7 +303,9 @@ class NostrClient( if (success) { activeRequests.onSent(relay.url, cmd) activeCounts.onSent(relay.url, cmd) - eventOutbox.onSent(relay.url, cmd) + eventOutbox.onSent(relay.url, cmd)?.let { gaveUp -> + listeners.forEach { it.onEventGaveUp(relay, gaveUp) } + } } listeners.forEach { it.onSent(relay, cmdStr, cmd, success) } } @@ -369,6 +371,8 @@ class NostrClient( override fun activeOutboxCache(url: NormalizedRelayUrl): Set = eventOutbox.activeOutboxCacheFor(url) + override fun activeOutboxEvents(url: NormalizedRelayUrl): List = eventOutbox.activeOutboxEventsFor(url) + override fun pendingPublishRelaysFor(eventId: HexKey): Set? = eventOutbox.pendingRelaysFor(eventId) override fun getReqFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt index 90d4c167b8..c3e4be1810 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.listeners +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -70,6 +71,16 @@ interface RelayConnectionListener { relay: IRelayClient, errorMessage: String, ) {} + + /** + * The client exhausted its retry budget for [event] on [relay] and dropped it without a + * confirmation. Use this to surface a failed delivery instead of losing it silently; the + * event may still be pending on other relays. + */ + fun onEventGaveUp( + relay: IRelayClient, + event: Event, + ) {} } object EmptyConnectionListener : RelayConnectionListener diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index 0e4506a842..c249d2aa3e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -96,6 +96,21 @@ class PoolEventOutbox { return myEvents } + /** + * The events still pending delivery to [url]. Unlike [activeOutboxCacheFor] (ids only), this + * returns the full events so callers can inspect kind/tags — e.g. to explain *why* a relay is + * being authenticated with (a pending gift wrap => sending a DM to its recipient). + */ + fun activeOutboxEventsFor(url: NormalizedRelayUrl): List { + val myEvents = mutableListOf() + eventOutbox.forEach { (_, outboxCache) -> + if (url in outboxCache.relaysRemaining) { + myEvents.add(outboxCache.event) + } + } + return myEvents + } + /** * Returns the relays that have NOT yet acknowledged [eventId] with an OK, or * null if the event is not currently tracked (never sent or already fully done). @@ -118,16 +133,19 @@ class PoolEventOutbox { return eventOutbox[event.id]?.remainingRelays() ?: emptySet() } + /** Records a send attempt. Returns the event if this attempt exhausted its retry budget for + * [url] (i.e. we gave up delivering it there), or null otherwise. */ fun newTry( id: HexKey, url: NormalizedRelayUrl, - ) { - val waiting = eventOutbox[id] - waiting?.newTry(url) - if (waiting?.isDone() == true) { + ): Event? { + val waiting = eventOutbox[id] ?: return null + val gaveUp = waiting.newTry(url) + if (waiting.isDone()) { eventOutbox = eventOutbox - waiting.event.id updateRelays() } + return if (gaveUp) waiting.event else null } fun newResponse( @@ -169,14 +187,11 @@ class PoolEventOutbox { } } + /** Returns the event if this send attempt made us give up delivering it to [relay]. */ fun onSent( relay: NormalizedRelayUrl, cmd: Command, - ) { - if (cmd is EventCmd) { - newTry(cmd.event.id, relay) - } - } + ): Event? = if (cmd is EventCmd) newTry(cmd.event.id, relay) else null fun sendToRelayIfChanged( event: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index a296709711..f3f857d8c4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -48,17 +48,21 @@ class PoolEventOutboxState( fun remainingRelays() = relaysRemaining - fun newTry(url: NormalizedRelayUrl) { + /** Records a send attempt to [url]. Returns true if the retry budget is now exhausted and the + * relay was dropped (i.e. we gave up delivering this event to [url]). */ + fun newTry(url: NormalizedRelayUrl): Boolean { val currentTries = failures[url] if (currentTries != null) { currentTries.addTriedTime(TimeUtils.now()) if (currentTries.isDone()) { relaysRemaining = relaysRemaining - url failures = failures - url + return true } } else { failures = failures + (url to Tries(listOf(TimeUtils.now()))) } + return false } fun newResponse( @@ -70,9 +74,15 @@ class PoolEventOutboxState( relaysRemaining = relaysRemaining - url failures = failures - url } else if (message.isAuthRequired()) { - // NIP-42 AUTH challenge in flight — don't count toward the try cap. - // RelayAuthenticator signs + relay re-issues OK; syncFilters() then - // re-pumps this outbox so the original publish is retried. + // NIP-42 AUTH challenge in flight. The relay responded, so it's up and simply wants + // auth first: RelayAuthenticator signs, the relay re-issues OK, and syncFilters() + // re-pumps this outbox to retry the publish. Reset the retry budget for this relay + // (clear both tries and responses) so the send attempts accumulated across reconnects / + // slow AUTH rounds can't exhaust the cap and drop the event before AUTH lands. Note + // newTry() (the send path) grows `tries` and is NOT auth-aware, so only clearing + // `responses` would still let a re-pumped event give up here. relaysRemaining is left + // as-is: the event must stay pending for this relay until AUTH unlocks it. + failures = failures - url } else { val currentTries = failures[url] if (currentTries != null) { @@ -95,7 +105,12 @@ class PoolEventOutboxState( this.startsWith("deleted:") || this.startsWith("invalid:") - fun String.isAuthRequired() = this.startsWith("auth-required:") + /** + * NIP-42 machine-readable prefix a relay uses to tell us an event was held + * back pending authentication. The event should be re-sent after AUTH, not + * retried-then-discarded like an ordinary failure. + */ + fun String.isAuthRequired() = this.startsWith("auth-required:") || this == "auth-required" // Tries 3 times class Tries( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt new file mode 100644 index 0000000000..f8250eb9a1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt @@ -0,0 +1,170 @@ +/* + * 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Regression coverage for NIP-42 handling in the outgoing-event retry queue. + * + * An `auth-required` NAK must be treated as "deferred pending AUTH", never as a + * retry that eats the [PoolEventOutboxState.Tries] budget — otherwise a relay + * that NAKs every unauthenticated EVENT would drop the message before the AUTH + * handshake completes. + */ +class PoolEventOutboxAuthTest { + private val relay = NormalizedRelayUrl("wss://auth.relay.test") + + private fun event(id: String) = + Event( + id = id, + pubKey = "00".repeat(32), + createdAt = 1_700_000_000L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = "00".repeat(64), + ) + + private fun PoolEventOutbox.publish( + event: Event, + relays: Set, + ) { + markAsSending(event, relays) + // simulate the actual EVENT frame going out on the wire + relays.forEach { onSent(it, EventCmd(event)) } + } + + private fun PoolEventOutbox.nak( + event: Event, + relay: NormalizedRelayUrl, + message: String, + ) = onIncomingMessage(relay, OkMessage(event.id, false, message)) + + private fun PoolEventOutbox.ok( + event: Event, + relay: NormalizedRelayUrl, + ) = onIncomingMessage(relay, OkMessage(event.id, true, "")) + + @Test + fun authRequiredSurvivesResendAfterAuth() = + runTest { + val outbox = PoolEventOutbox() + val ev = event("aa".repeat(32)) + + outbox.publish(ev, setOf(relay)) + + // Relay NAKs every unauthed EVENT with auth-required, more times than the + // 3-response retry budget. These must NOT be recorded as failures, or the + // post-auth resend below would trip Tries.isDone() and drop the event. + repeat(3) { outbox.nak(ev, relay, "auth-required: we can't serve unauthed writes") } + assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) + + // AUTH completes -> syncFilters re-sends the still-pending event. + val resent = mutableListOf() + outbox.syncState(relay) { resent.add(it) } + assertEquals(1, resent.size) + assertTrue(resent.first() is EventCmd) + + // The resend records a new try. With the pre-fix behavior the poisoned + // budget would drop the event right here; it must still be pending. + outbox.onSent(relay, resent.first()) + assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) + + // Relay now accepts the authenticated event. + outbox.ok(ev, relay) + assertNull(outbox.pendingRelaysFor(ev.id)) + } + + @Test + fun authRequiredResetsTheTriesBudgetAcrossManyResends() { + val outbox = PoolEventOutbox() + val ev = event("ff".repeat(32)) + + outbox.publish(ev, setOf(relay)) // first try + + // A flapping relay / slow AUTH handshake re-pumps the still-pending event many more times + // than the 4-try cap, each NAK'd auth-required. newTry() (the send path) grows `tries` and + // is not auth-aware, so unless auth-required resets the retry budget these sends would trip + // Tries.isDone() and drop the event (with a spurious give-up) before AUTH ever lands. + repeat(8) { i -> + assertNull(outbox.onSent(relay, EventCmd(ev)), "must not give up on re-pump $i") + outbox.nak(ev, relay, "auth-required: authenticate first") + } + assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) + + // AUTH finally completes -> the event delivers. + outbox.ok(ev, relay) + assertNull(outbox.pendingRelaysFor(ev.id)) + } + + @Test + fun terminalRejectionStillDiscardsImmediately() { + val outbox = PoolEventOutbox() + val ev = event("cc".repeat(32)) + + outbox.publish(ev, setOf(relay)) + outbox.nak(ev, relay, "invalid: bad signature") + + assertNull(outbox.pendingRelaysFor(ev.id)) + } + + @Test + fun givesUpAndSignalsAfterExhaustingTryBudget() { + val outbox = PoolEventOutbox() + val ev = event("ee".repeat(32)) + + // markAsSending + first onSent (1 try). Tries budget is >3 tries. + outbox.publish(ev, setOf(relay)) + // attempts 2, 3 stay under budget and signal nothing. + assertNull(outbox.onSent(relay, EventCmd(ev))) + assertNull(outbox.onSent(relay, EventCmd(ev))) + // the 4th attempt exhausts the budget -> event is returned (gave up) and dropped. + assertEquals(ev.id, outbox.onSent(relay, EventCmd(ev))?.id) + assertNull(outbox.pendingRelaysFor(ev.id)) + } + + @Test + fun ordinaryTransientFailureStillBounded() { + val outbox = PoolEventOutbox() + val ev = event("dd".repeat(32)) + + outbox.publish(ev, setOf(relay)) + // 3 non-auth error responses exhaust the retry budget. Responses only + // accumulate here; the drop happens on the next send attempt. + repeat(3) { outbox.nak(ev, relay, "error: rate-limited") } + assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) + + // The next resend attempt observes the exhausted budget and drops the event + // (unlike auth-required, which never poisons the budget). + outbox.onSent(relay, EventCmd(ev)) + assertNull(outbox.pendingRelaysFor(ev.id)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index f7213b655f..f86af44b03 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -104,6 +104,8 @@ private class TrackingNostrClient : INostrClient { override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + override fun activeOutboxEvents(url: NormalizedRelayUrl): List = emptyList() + override fun close() {} } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt index 3f346531b8..77ca2c5d3c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt @@ -431,6 +431,8 @@ private class CapturingNostrClient : INostrClient { override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + override fun activeOutboxEvents(url: NormalizedRelayUrl): List = emptyList() + override fun close() {} } @@ -490,5 +492,7 @@ private class CountingNostrClient( override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() + override fun activeOutboxEvents(url: NormalizedRelayUrl): List = emptyList() + override fun close() {} }