diff --git a/amethyst/plans/2026-07-20-v1.13.0-release-qa.md b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md new file mode 100644 index 0000000000..e71cc638ab --- /dev/null +++ b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md @@ -0,0 +1,190 @@ +# v1.13.0 release QA — coverage, open findings, and recipes + +One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on +`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own +root-cause reasoning and is the better reference for *why* a given change looks the way it does. + +This document records what that session **could not** capture in commit messages: what was actually +exercised, what was not, what we chose to leave broken, and how to reproduce the setups. + +**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64. +Everything below is that one configuration unless stated. + +--- + +## 1. Coverage + +### Exercised on device + +| Area | Notes | +|---|---| +| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start | +| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar | +| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B | +| Launch-account signing binding | desync reproduced end to end, then verified fixed | +| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) | +| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate | +| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution | +| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming | +| Location | map picker + teleport, before/after on 4 symptoms, composer path | +| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months | +| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) | + +### Fixed but **only unit-verified** — never run on a device + +Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head · +**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF +validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display · +**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure · +`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators · +podcast duplicate description · Concord leave affordance · the three revocation wirings. + +### Never opened at all + +Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock +actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast +chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles · +real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess, +Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams. + +NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and +pinned messages were never exercised. + +### Platform gaps + +- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested. + An emulator makes this cheap and it is the highest-value remaining gap. +- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real + `release` (full R8) has never been built or run. **arm64 only.** +- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk. +- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored. + +--- + +## 2. Open findings (known, deliberately not fixed) + +**Release mechanics** +- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode. +- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged + manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release + ships blind. + +**Correctness / UX** +- Tor settings do not take effect until app restart, with no indication. +- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty. +- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster. +- Concord: leaving does not unpin from the bottom bar, leaving a dead tab. +- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong). +- Modal geohash picker header is overdrawn by the MapView (pre-existing). +- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`. +- `amy login bunker://…` hangs and never delivers a `connect`. + +**Security / protocol** +- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and + revokes nothing. +- WebView storage profiles are never deleted on logout — a removed account's cookies persist. + Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`. +- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON + keys; only the community list was fixed. +- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a + real community's identity with an attacker's root. **Armada has the identical gap** — this needs a + spec conversation, not a unilateral fix. +- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.** + Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list* + entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5 + moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses + every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its + `banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses + the rank-aware `canActOnPosition`. + **This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was + wrong. CORD-04 §3 is explicit and normative: "One hard rule binds every action: the actor must + hold the required bit **and** *strictly* outrank its target — equal cannot act on equal (an admin + cannot ban a peer admin)", restated as step 3 of §5. Only §4, the section that defines the + Banlist, omits the rank half — and both independent implementations read §4 in isolation and made + the same mistake. Spec: (`04.md`). + **FIXED and shipping** — `AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only + add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted + entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the + ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the + *honored* banlist via the resolver instead of decoding the raw head, which also closes a + laundering path where our own next ban would re-publish an unauthorized entry under our signature. + **Known consequence: Armada has not shipped this, so banlists can differ between clients** — + we ignore a ban Armada honors when the signer did not outrank the target. Deliberate. + Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`. + Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the + gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a + genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban + that does not chain onto it. +- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be + found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note + loaded, so deep history stays partially unresolved. Cosmetic, pre-existing. + +--- + +## 3. Setup recipes + +**`amy` with an isolated identity** (never touch the maintainer's real one): + +``` +AMY=$(pwd)/cli/build/install/amy/bin/amy +H=/tmp/qa-home; mkdir -p $H +HOME=$H $AMY --account qa login --secret-backend plaintext +``` +`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use +`--secret-backend plaintext` for throwaway identities. + +**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com` +returned success but published nothing; `groups.0xchat.com` worked. Always confirm with +`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link: +`adb shell am start -a android.intent.action.VIEW -d "nostr:"`, encoded via +`amy encode naddr --pubkey --kind 39000 --identifier --relay `. +To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then +`put-user … --role admin`. + +**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with +`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before +and after the user action — that turns "I didn't see traffic" into an actual measurement. + +--- + +## 4. Patterns worth acting on + +These recurred often enough to be process problems rather than individual bugs. + +**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test +named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on +well-formed input, a napplet session test that never crossed accounts. *Always verify a new +regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date +`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`. + +**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`, +`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and +`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model +while being unreachable to users, and the first one actually invoked turned out to be **broken as +written**. A lint for "public capability with no caller outside its declaring file" would catch the +whole class cheaply. + +**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays +for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a +quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map +is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary +is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit` +already bounds these queries, which is what makes dropping the time floor safe. + +**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in +title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a +`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and +NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds +with others`), not membership modelling. Instrument first. + +**Check the reference implementation.** Reading Armada changed the answer three times out of three — +it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix" +that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after* +folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing. + +**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like +an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race. +Removing it would have been a confident, review-passing regression. + +**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently +committed another worker's temporary revert. Stage explicit paths, always. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index c615047be8..2e9b8fb209 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -32,6 +32,9 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.File /** @@ -95,6 +98,10 @@ class Amethyst : Application() { // Index device-local captured favicons (main process only; decorates favorites + suggestions). BrowserIconRegistry.init(this) + // Warm the global-settings prefs off-main so the first (deliberately synchronous) read of + // them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings. + CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() } + // Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor // exits) starts on the open web without first flashing a failed Tor load. WebAppNetworkRegistry.init(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 957182ed46..649f70a47f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -27,6 +27,7 @@ import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash @@ -694,8 +695,32 @@ class AppModules( // Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions, // backed by a file under accounts//), so there is no app-wide store here anymore. + /** + * The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each + * call (never captured) so an account switch immediately moves embedded apps to the new account's + * namespace: an app authorized by one npub is never authorized under another. + */ + val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" } + // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. - val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } + val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) } + + /** + * The one napplet permission ledger for the main process. Its persistent half is just the store + * above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every + * caller shares this instance. The broker service and the Connected Apps screens used to build + * a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty) + * session map while the grants the broker was actually consulting lived on untouched. + * + * Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s + * onDestroy (all applet/browser surfaces gone), which calls `endSession()`. + */ + val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) } + + // NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already + // carry their owning account (`nip46::`) and whose sessions run for a specific + // account rather than the active one. The napplet path namespaces its own coordinate the same way + // (see NappletBroker.signerCoordinateFor) instead. val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) } // Display + relay info for connected NIP-46 remote-signer clients. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8c5c6f9480..9603f78862 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -236,6 +236,19 @@ object LocalPreferences { // the source of truth, so there is no async hydrate that could clobber a user toggle. private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE) + /** + * Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main + * thread, so the first synchronous read below hits memory rather than disk. + * + * The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async + * hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms + * the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it + * simply pays the disk hit once, exactly as before. + */ + fun warmGlobalSettings() { + globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true) + } + private val notificationServiceEnabled: MutableStateFlow by lazy { MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index e9528737f2..0e9f7edc40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -193,6 +193,16 @@ private fun SignerConsentDialog( if (info.accountName != null) { ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey) } + // For a decrypt request, WHOSE conversation is being read is the decision. Show + // that person as an avatar + name, never as nothing. + if (info.counterpartyName != null) { + Text( + stringResource(R.string.nip46_signer_messages_with), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey) + } } Spacer(Modifier.height(12.dp)) @@ -204,12 +214,31 @@ private fun SignerConsentDialog( HorizontalDivider() Spacer(Modifier.height(8.dp)) - // Primary: always allow this op - Button( - onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_consent_allow_always)) + // Primary: the NARROWEST "remember" available. For decrypt that is "always allow for + // Alice" — one broad decrypt grant would otherwise hand over every conversation + // forever, and scoping the op itself would mean a prompt per conversation. + val narrowOp = info.narrowOp + if (narrowOp != null && info.narrowOpLabel != null) { + Button( + onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(info.narrowOpLabel) + } + // The broad grant stays available, but demoted below the scoped one. + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + } else { + Button( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } } // Secondary: allow just once diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index 92730ea5b7..ac3f5247fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -65,6 +65,23 @@ data class SignerConsentInfo( * non-event ops. */ val previewTemplate: EventTemplate? = null, + /** + * The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as + * an avatar + name. "X wants to read your messages with Alice" is a categorically different + * decision from "X wants to read your private messages", so this must reach the dialog. + * Null for every op that has no counterparty (signing, and the napplet/browser paths). + */ + val counterpartyName: String? = null, + val counterpartyPicture: String? = null, + val counterpartyPubKey: String? = null, + /** + * A NARROWER op the dialog may offer to remember instead of [op] — today only + * [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e. + * "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so + * the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text. + */ + val narrowOp: NostrSignerOp? = null, + val narrowOpLabel: String? = null, ) /** One pending per-operation consent request, as the batched sheet renders it. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt index 1623d160d7..414e44e371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt @@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.favorites import android.content.Context import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.io.File /** @@ -50,12 +54,28 @@ object BrowserIconRegistry { @Volatile private var iconDir: File? = null - /** Binds the app context and indexes already-stored icons. Idempotent. */ + // Disk work runs here, never on the caller's thread. Both entry points are reached from threads + // that must not block: init() from app startup and record() from the broker's IPC handler, which + // is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the + // UI while a favicon was saved. + private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * Binds the app context and indexes already-stored icons. Idempotent. + * + * [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the + * directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its + * placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival + * drives recomposition. + */ fun init(context: Context) { if (iconDir != null) return - val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() } + val dir = File(context.applicationContext.filesDir, DIR) iconDir = dir - _keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet() + io.launch { + dir.mkdirs() + _keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet() + } } /** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */ @@ -66,11 +86,17 @@ object BrowserIconRegistry { val dir = iconDir ?: return if (host.isBlank() || bytes.isEmpty()) return val key = sanitize(host) - try { - File(dir, key + PNG).writeBytes(bytes) - _keys.update { it + key } - } catch (e: Exception) { - Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e) + // Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk. + // [keys] updates only after the bytes are actually on disk, so a reader can never be told an + // icon exists before the file backing it does. + io.launch { + try { + dir.mkdirs() + File(dir, key + PNG).writeBytes(bytes) + _keys.update { it + key } + } catch (e: Exception) { + Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index f9ab3183e9..bfdc4466ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplet.NappletLauncher +import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity @@ -92,9 +93,20 @@ object FavoriteAppLauncher { } val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url") val intent = - NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply { - if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - } + NappletBrowserActivity + .intent( + context, + url, + proxyPort, + useTor, + theme = theme, + isFavorite = isFavorite, + // Opaque per-account storage partition, so a web app can't carry one npub's session + // into another. Derived here (the sandbox never sees the pubkey). + webViewProfile = NappletWebViewProfiles.current(), + ).apply { + if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } context.startActivity(intent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 04d9e3c3b8..4207c80def 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage @@ -363,6 +364,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash @@ -372,6 +374,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" +/** + * How often a joined Concord community's stored invite link is re-resolved to check whether + * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is + * rare and silent, so this trades detection latency for not turning the revision tick into a + * relay-fetch loop. + */ +private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L + @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -2096,6 +2106,16 @@ class Account( * bundle we can't open (e.g. minted by a newer client) must not strand the user * on a spinner that retries forever. * + * A bundle whose `expires_at` has passed is rejected with + * [ConcordInviteResult.Expired]. Expiry is resolved inside + * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather + * than being a field nobody reads. + * + * **This must only ever be called from an explicit user action.** It contacts + * relay URLs carried in the link (chosen by whoever minted it) and publishes a + * Guestbook JOIN signed by this account, so calling it on deep-link arrival would + * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. + * * If the resolved community is already in the joined list, this returns * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook * JOIN, so reopening an old invite for a community you're already in simply takes @@ -2118,6 +2138,7 @@ class Account( val bundle = when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable @@ -2141,6 +2162,10 @@ class Account( relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.now() * 1000, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), ) joinConcordCommunity(entry) return ConcordInviteResult.Joined(bundle.communityId) @@ -2349,7 +2374,7 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now()) + val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2411,12 +2436,12 @@ class Account( val roleIdHex = existing?.key ?: run { val roleId = RandomInstance.bytes(32) - val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now()) + val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, roleWrap) roleId.toHexKey() } - val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now()) + val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) return true } @@ -2428,17 +2453,26 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now()) + val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) return true } /** * If [note] is a Concord channel message whose author this account is allowed to - * ban — the actor is the owner or holds the BAN permission, and the target is - * neither the owner nor the actor — returns `(communityId, memberHex)`. Null - * otherwise, so the UI shows the Ban action only when it would actually take - * effect on fold. + * ban — the actor outranks the target and holds the BAN permission, and the target + * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null + * otherwise, so the UI offers Ban only where we are willing to act. + * + * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the + * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's + * rank-checks the *contents* of a banlist edition — both gate only on the author's + * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its + * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin + * above them is therefore *accepted* by every client today. Since we cannot refuse + * such a ban without diverging from Armada, we at least refuse to author one — this + * restricts what we write, never what we accept, so it cannot split consensus. + * Enforcing it on the fold needs a spec change; see the QA plan's open findings. */ fun concordBanTarget(note: Note): Pair? { val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null @@ -2452,7 +2486,11 @@ class Account( ?.value ?.authority ?: return null if (authority.isOwner(author)) return null - val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) + // The owner short-circuits rather than going through canActOn: canActOn starts at + // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put + // the owner on the banlist (see the KDoc) — routing the owner through it would let them be + // locked out of moderating their own community. + val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN) return if (canBan) communityId to author else null } @@ -2463,7 +2501,7 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now()) + val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2475,7 +2513,7 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now()) + val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2489,7 +2527,7 @@ class Account( /** * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, * roll the `community_root`, re-key every retained member (Guestbook membership ∪ - * the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted + * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted * Control Plane under the new root. A removed member keeps the prior root (so * their history stays readable) but receives no blob, so they can never decrypt * anything published after the rotation. @@ -2514,14 +2552,23 @@ class Account( // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally // first, so each subsequent edition chains onto the updated banlist head. for (target in removedLower) { - val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now()) + val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, banWrap) } - // 2. Recipient set: everyone we're keeping — Guestbook joins ∪ roster ∪ self, minus the - // removed and the already-banned. + // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. + // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the + // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other + // clients need not), so building the set without observed authors silently expelled every + // member who had only ever posted: they hold no role, receive no blob, and the Refounding + // strands them. That mainly hit cross-client communities, where Armada members are the + // bulk of the roster. + // + // Still a floor, not a census (see allMembers): a member who joined without a Guestbook + // motion, holds no role, and has never posted leaves no trace to find, so a Refounding + // cannot re-key them. Stranded recovery is what gets those members back. val recipients = - (session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey) + (session.allMembers() + signer.pubKey) .mapTo(HashSet()) { it.lowercase() } .apply { removeAll(removedLower) @@ -2589,6 +2636,13 @@ class Account( relays = entry.relays, name = entry.name, addedAt = entry.addedAt, + // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left + // out of would be unrecoverable. + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) + // must survive our rotation write, or we delete their data on every rekey. + residue = entry.residue, ) sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) @@ -2597,10 +2651,23 @@ class Account( /** * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for * each joined community, look for our new root among the kind-3303 wraps seen at - * our next base-rekey address. If a role-authorized rotator (owner or a current - * BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session - * rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap - * never re-triggers. Called on every Concord revision tick. + * our next base-rekey address. If a role-authorized rotator (owner or a current, + * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the + * session rebuilds at the new epoch and its next-rekey address moves on, so a stale + * wrap never re-triggers. Called on every Concord revision tick. + * + * Authority is the roster, never key possession: any non-banned BAN-holder may + * rotate, including for the owner. The owner deliberately does NOT refuse a root + * authored by someone else — refusing would strand the owner alone on the dead + * epoch whenever an admin legitimately rotates, and would diverge from Armada, + * which forks a community across clients. Self-escalation to BAN is prevented + * upstream by the role rank gate in AuthorityResolver. + * + * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, + * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the + * owner included) by omission — nothing on this receive path can prevent it. The + * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves + * the invite link the membership was joined through and merges forward. */ private suspend fun drainConcordRekeys() { if (!isWriteable()) return @@ -2618,12 +2685,76 @@ class Account( ) ?: continue if (received.newEpoch <= entry.rootEpoch) continue val authority = session.state.value?.authority ?: continue - val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN) + + // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder + // who has themselves been banned could still rotate the whole community. + val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) if (!authorized) continue adoptConcordRoot(entry, received.newRoot, received.newEpoch) } } + // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the + // Concord revision tick (which fires on every structural change) without turning it into a + // relay-fetch loop. + private val lastConcordRecoveryCheck = ConcurrentHashMap() + + /** + * Stranded recovery (CORD-05/06 receive path). A Refounding carries only + * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left + * out of the rekey recipient set receives nothing and sits on the dead epoch + * forever while everyone else moves on. This happens to any member, the owner + * included, and [drainConcordRekeys] cannot prevent it: there is no message to + * miss detecting. + * + * The way back is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and + * carried through every rotation by [adoptConcordRoot]). The community keeps + * re-minting its bundle at that same addressable coordinate, so a bundle there at + * a **strictly higher** epoch than ours proves we were left behind — and carries + * the new root. Same or lower epoch is a no-op. Memberships with no link (direct + * invites, legacy entries) are inert here; that is expected, not an error. + * + * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps + * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the + * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays + * derivable). We then re-announce the Guestbook at the new epoch, exactly as an + * ordinary rotation does, so the recovered member is visible to whoever refounds + * next instead of being silently dropped again. + * + * Called on the Concord revision tick, but rate-limited per community + * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. + */ + private suspend fun recoverStrandedConcordCommunities() { + if (!isWriteable()) return + val now = TimeUtils.nowMillis() + for (entry in concordChannelList.liveCommunities.value) { + val inviteRef = entry.inviteRef ?: continue + val last = lastConcordRecoveryCheck[entry.id] + if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue + lastConcordRecoveryCheck[entry.id] = now + + val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue + val relays = + ( + parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ).toSet() + if (relays.isEmpty()) continue + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = client.fetchAll(filters = filters) + // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue + + val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue + if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue + Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") + sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged)) + announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + } + } + /** * Replace the community metadata (name / icon / description / relays) with a new * Control-Plane edition. Honored on fold only when this account holds @@ -2640,7 +2771,7 @@ class Account( val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) - val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now()) + val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2658,7 +2789,7 @@ class Account( if (!isWriteable()) return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2671,8 +2802,16 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now()) + // Carry the standing definition forward and change only the name. A ChannelEntity built from + // scratch defaults `private` and `voice` to false, so renaming a private channel used to + // publish an edition declaring it PUBLIC — and a voice channel became a text channel. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2685,8 +2824,15 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val channel = ChannelEntity(name = name.trim(), deleted = true) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now()) + // Same as rename: preserve the standing flags so a tombstone does not also silently + // reclassify the channel it retires. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -4922,7 +5068,10 @@ class Account( else -> event.content } } else { - event.content + // A read-only (npub-only) account holds no key, so nothing above can run. Returning + // `content` verbatim would push the raw NIP-04/NIP-44 base64 blob straight into the + // UI (chat bubbles, Messages previews, ...). Callers treat null as "not readable". + if (event.hasEncryptedContent()) null else event.content } } @@ -4949,6 +5098,11 @@ class Account( draftsDecryptionCache.cachedDraft(event)?.content } + // Encrypted kinds that reached here did so because this account is not writeable + // (every branch above is gated on isWriteable). Their `content` is ciphertext — + // hand back null rather than let the blob render. See cachedDecryptContent. + event != null && event.hasEncryptedContent() -> null + else -> { event?.content } @@ -5379,6 +5533,9 @@ class Account( refreshConcordChannelIndex() // A revision also bumps when a base-rotation rekey lands; adopt ours if present. runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + // A rotation we were *excluded* from produces no rekey to drain, so it can only be + // found by re-resolving the invite link we joined through. Rate-limited internally. + runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 6c9cc50222..6b4d2599cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -47,6 +47,13 @@ sealed interface ConcordInviteResult { */ data object Revoked : ConcordInviteResult + /** + * The bundle opened fine, but its `expires_at` has passed. Retrying can't help — + * unlike [Revoked] the owner didn't retire the link, it simply timed out, so the + * user's next step is to ask for a fresh one. + */ + data object Expired : ConcordInviteResult + /** * The bundle event was found but could not be opened with the link's token — * typically because it was minted by a newer/incompatible Concord client whose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index caf6de75ec..104166e77a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -29,14 +29,14 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator -import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.napplet.label import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt import kotlinx.coroutines.withTimeoutOrNull /** @@ -119,39 +119,43 @@ object Nip46ConsentBridge { } ?: AppConnectResult.Cancelled } - /** Per-operation consent: describe the request (op + event preview) and await the user's grant. */ + /** + * Per-operation consent: describe the request and await the user's grant. + * + * For a decrypt request this DECRYPTS FIRST and shows the resulting plaintext, together with the + * counterparty the conversation is with. That is what makes the decision reviewable: without it + * the dialog said only "wants to read your private messages" with no way to tell one request from + * another. Decryption is local — [signer] runs on this device and nothing leaves it unless the + * user approves — and it is bounded by [Nip46ConsentInfoBuilder.DECRYPT_PREVIEW_TIMEOUT_MS] so a slow or failing signer + * degrades to an explanatory message instead of hanging or blanking the prompt. + */ suspend fun requestOp( coordinate: String, clientPubKey: HexKey, op: NostrSignerOp, request: BunkerRequest, + signer: NostrSigner, ): SignerOpGrant { val context = Amethyst.instance.appContext val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull() val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) - val preview = - if (request is BunkerRequestSign) { - request.event.content - .take(160) - .trim() - } else { - "" - } - val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else "" - val face = accountFace(coordinate) + val consentInfo = - SignerConsentInfo( - appletTitle = title, + Nip46ConsentInfoBuilder.build( coordinate = coordinate, - op = op, - operationSummary = op.label(context), - contentPreview = preview, - rawData = rawData, + title = title, iconUrl = info?.image, - accountName = face.name, - accountPicture = face.picture, - accountPubKey = face.pubKey, - previewTemplate = (request as? BunkerRequestSign)?.event, + op = op, + request = request, + account = accountFace(coordinate), + faceOf = ::userFace, + strings = + Nip46ConsentStrings( + opLabel = { it.label(context) }, + allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) }, + decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed), + ), + decrypt = { decryptWithAccountSigner(signer, it) }, ) // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { @@ -159,16 +163,30 @@ object Nip46ConsentBridge { } ?: SignerOpGrant.DenyOnce } + /** + * Performs the local decryption behind the decrypt preview with the account's own signer. Errors + * and timeouts are handled by [Nip46ConsentInfoBuilder]; this only maps the request to a call. + */ + private suspend fun decryptWithAccountSigner( + signer: NostrSigner, + request: BunkerRequest, + ): String? = + when (request) { + is BunkerRequestNip04Decrypt -> signer.nip04Decrypt(request.ciphertext, request.pubKey) + is BunkerRequestNip44Decrypt -> signer.nip44Decrypt(request.ciphertext, request.pubKey) + else -> null + } + /** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */ - private fun accountFace(coordinate: String): AccountFace { + private fun accountFace(coordinate: String): SignerFace { val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate) val user = pubKey?.let { LocalCache.getUserIfExists(it) } - return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey) + return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey) } - private data class AccountFace( - val name: String?, - val picture: String?, - val pubKey: String?, - ) + /** Cached profile for a counterparty; the builder supplies the shortened-npub fallback. */ + private fun userFace(pubKey: HexKey): SignerFace { + val user = LocalCache.getUserIfExists(pubKey) + return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilder.kt new file mode 100644 index 0000000000..c31b73dc4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilder.kt @@ -0,0 +1,176 @@ +/* + * 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.model.nip46Signer + +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull + +/** Avatar + display name for one pubkey, as the consent dialogs render it. */ +data class SignerFace( + val name: String?, + val picture: String?, + val pubKey: String?, +) + +/** + * The user-visible strings the builder needs, injected rather than read from `R.string` so the + * builder itself carries no Android dependency and can be unit-tested. + */ +class Nip46ConsentStrings( + /** Human-readable label for an op, e.g. "read your private messages with Alice". */ + val opLabel: (NostrSignerOp) -> String, + /** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */ + val allowAlwaysFor: (String) -> String, + /** Shown as the preview when Amethyst itself could not decrypt the message. */ + val decryptFailed: String, +) + +/** + * Builds the [SignerConsentInfo] for one NIP-46 per-operation prompt. + * + * Split out of [Nip46ConsentBridge] (which owns the Android `Context`/`LocalCache` lookups) so the + * decisions that matter for safety are testable without an emulator: + * - a decrypt request is DECRYPTED FIRST and the plaintext becomes the preview, honouring the + * contract the dialog documented but never implemented; + * - a decrypt that cannot be decrypted still produces a populated dialog, never a blank one; + * - the counterparty label is never empty — it degrades to a shortened npub, never to nothing. + */ +object Nip46ConsentInfoBuilder { + /** Characters of plaintext/content shown inline before the "show more" toggle takes over. */ + const val PREVIEW_MAX_CHARS = 160 + + /** + * Upper bound on the pre-consent decryption. Short on purpose: the preview is a nicety, the + * prompt is not, so a signer that stalls (e.g. an external NIP-55 app that is not responding) + * must not delay the dialog. + */ + const val DECRYPT_PREVIEW_TIMEOUT_MS = 8_000L + + suspend fun build( + coordinate: String, + title: String, + iconUrl: String?, + op: NostrSignerOp, + request: BunkerRequest, + account: SignerFace, + /** Resolves a pubkey to a cached profile; the builder supplies its own npub fallback. */ + faceOf: (HexKey) -> SignerFace, + strings: Nip46ConsentStrings, + /** Performs the local decryption. May fail, return null, or hang — all are handled. */ + decrypt: suspend (BunkerRequest) -> String?, + ): SignerConsentInfo { + val counterparty = request.decryptCounterparty() + val plaintext = if (counterparty != null) decryptPreview(request, decrypt, strings.decryptFailed) else null + + val preview = + when { + request is BunkerRequestSign -> + request.event.content + .take(PREVIEW_MAX_CHARS) + .trim() + plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim() + else -> "" + } + val rawData = + when { + request is BunkerRequestSign -> JacksonMapper.toJsonPretty(request.event) + // Only worth a "show more" toggle when the preview actually truncated it. + plaintext != null && plaintext.length > PREVIEW_MAX_CHARS -> plaintext + else -> "" + } + + // A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to + // the broad "always allow", instead of only the all-conversations-forever choice. + val narrowOp = request.toNarrowSignerOp() + val counterpartyFace = counterparty?.let { face(it, faceOf) } + + return SignerConsentInfo( + appletTitle = title, + coordinate = coordinate, + op = op, + // For decrypt this names the counterparty ("read your private messages with Alice"). + operationSummary = strings.opLabel(narrowOp ?: op), + contentPreview = preview, + rawData = rawData, + iconUrl = iconUrl, + accountName = account.name, + accountPicture = account.picture, + accountPubKey = account.pubKey, + previewTemplate = (request as? BunkerRequestSign)?.event, + counterpartyName = counterpartyFace?.name, + counterpartyPicture = counterpartyFace?.picture, + counterpartyPubKey = counterparty, + narrowOp = narrowOp, + narrowOpLabel = counterpartyFace?.name?.let { strings.allowAlwaysFor(it) }, + ) + } + + /** + * Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails, + * refuses, returns nothing, or takes too long yields [failureText], because a request whose + * ciphertext we cannot even read is itself worth showing — a blank dialog is not. + */ + private suspend fun decryptPreview( + request: BunkerRequest, + decrypt: suspend (BunkerRequest) -> String?, + failureText: String, + ): String = + withTimeoutOrNull(DECRYPT_PREVIEW_TIMEOUT_MS) { + try { + decrypt(request)?.ifBlank { null } + } catch (e: CancellationException) { + // Includes this block's own timeout — must propagate so withTimeoutOrNull sees it. + throw e + } catch (e: Exception) { + Log.w("NIP46Signer") { "decrypt preview failed: ${e.message}" } + null + } + } ?: failureText + + /** [faceOf], but with a guaranteed non-blank name (shortened npub when the user isn't cached). */ + private fun face( + pubKey: HexKey, + faceOf: (HexKey) -> SignerFace, + ): SignerFace { + val resolved = runCatching { faceOf(pubKey) }.getOrNull() + return SignerFace( + name = resolved?.name?.ifBlank { null } ?: shortIdentifier(pubKey), + picture = resolved?.picture, + pubKey = pubKey, + ) + } + + /** A shortened npub for an uncached pubkey; falls back to the hex prefix if it isn't valid hex. */ + fun shortIdentifier(pubKey: HexKey): String { + val npub = runCatching { NPub.create(pubKey) }.getOrNull() + return if (!npub.isNullOrBlank()) npub.take(12) + "…" else pubKey.take(12) + "…" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 5466080f30..4fd726c943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -170,7 +170,12 @@ class Nip46SignerState( // connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds, // decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing. connectConsent = Nip46ConsentBridge::requestConnect, - opConsent = Nip46ConsentBridge::requestOp, + // The account's own signer goes to the bridge so a decrypt request can be decrypted + // BEFORE the prompt — the dialog shows the actual plaintext instead of an opaque + // "wants to read your private messages". Local only; nothing is disclosed until approval. + opConsent = { coordinate, clientPubKey, op, request -> + Nip46ConsentBridge.requestOp(coordinate, clientPubKey, op, request, signer) + }, ) init { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt index 90bdbf434c..073d458f83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt @@ -41,12 +41,21 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = " */ class DataStoreNappletPermissionStore( private val dataStore: DataStore, + private val accountPubKey: () -> String, ) : NappletPermissionStore { - constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore) + constructor(context: Context, accountPubKey: () -> String) : + this(context.applicationContext.nappletPermissionsDataStore, accountPubKey) + + /** + * Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves + * every read and write to that account's namespace with no rebuild — a grant made by one account + * can never authorize another. + */ + private fun scoped(coordinate: String) = "${accountPubKey()}$SEP$coordinate" override suspend fun load(coordinate: String): Map { val prefs = dataStore.data.first() - val prefix = "$coordinate$SEP" + val prefix = "${scoped(coordinate)}$SEP" val result = mutableMapOf() for ((key, value) in prefs.asMap()) { val name = key.name @@ -68,7 +77,7 @@ class DataStoreNappletPermissionStore( } override suspend fun clear(coordinate: String) { - val prefix = "$coordinate$SEP" + val prefix = "${scoped(coordinate)}$SEP" dataStore.edit { prefs -> val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) } toRemove.forEach { prefs.remove(it) } @@ -78,11 +87,15 @@ class DataStoreNappletPermissionStore( override suspend fun all(): Map> { val prefs = dataStore.data.first() val result = mutableMapOf>() + val accountPrefix = "${accountPubKey()}$SEP" for ((key, value) in prefs.asMap()) { val name = key.name - // Key is " "; the capability is the final space-delimited token. - val capName = name.substringAfterLast(SEP, "") - val coordinate = name.substringBeforeLast(SEP, "") + // Key is "". Only the active account's grants + // are listed, so the Connected Apps screen never surfaces another account's permissions. + if (!name.startsWith(accountPrefix)) continue + val scoped = name.removePrefix(accountPrefix) + val capName = scoped.substringAfterLast(SEP, "") + val coordinate = scoped.substringBeforeLast(SEP, "") if (capName.isEmpty() || coordinate.isEmpty()) continue val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue @@ -103,7 +116,7 @@ class DataStoreNappletPermissionStore( private fun keyOf( coordinate: String, capability: NappletCapability, - ) = stringPreferencesKey("$coordinate$SEP${capability.name}") + ) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}") companion object { private const val SEP = "\u0000" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 7ba5102c49..893f02ec87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -32,14 +32,20 @@ import kotlinx.coroutines.flow.first private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage") /** - * DataStore-backed [NappletStorage]. Every key is prefixed with the applet's coordinate, so one - * napplet's keys can never collide with another's, and this store is entirely separate from the - * app's own preferences. + * DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the + * applet's coordinate, so one napplet's keys can never collide with another's, one account's data is + * never visible to another, and this store is entirely separate from the app's own preferences. + * + * [accountPubKey] is read at call time rather than captured, so switching accounts moves reads and + * writes to the new namespace with no rebuild — an embedded applet always sees the current account's + * data and never the previous one's. */ class DataStoreNappletStorage( private val dataStore: DataStore, + private val accountPubKey: () -> String, ) : NappletStorage { - constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore) + constructor(context: Context, accountPubKey: () -> String) : + this(context.applicationContext.nappletStorageDataStore, accountPubKey) override suspend fun get( coordinate: String, @@ -62,7 +68,9 @@ class DataStoreNappletStorage( } override suspend fun keys(coordinate: String): List { - val prefix = "$coordinate " + // Must match keyOf's separator exactly. This filtered on a space while keys are written + // with NUL, so no key could ever match and keys() always returned an empty list. + val prefix = prefixOf(coordinate) return dataStore.data .first() .asMap() @@ -72,8 +80,11 @@ class DataStoreNappletStorage( .map { it.removePrefix(prefix) } } + /** Account first, then applet: isolates accounts from each other, and applets within an account. */ + private fun prefixOf(coordinate: String) = "${accountPubKey()}\u0000$coordinate\u0000" + private fun keyOf( coordinate: String, key: String, - ) = stringPreferencesKey("$coordinate\u0000$key") + ) = stringPreferencesKey(prefixOf(coordinate) + key) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 0eb2a39323..37a124c67e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter -import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry @@ -50,7 +49,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.screen.AccountState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -78,32 +77,37 @@ import kotlinx.coroutines.launch class NappletBrokerService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM. - private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + // Persistent grants on disk, session grants in RAM. Shared app-wide (see AppModules) so the + // Connected Apps screens revoke the very grants this broker consults; session grants are dropped + // in onDestroy, which is the "all applet surfaces closed" boundary. + private val ledger get() = Amethyst.instance.nappletPermissionLedger // Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only // instantiated in the main process where the signer lives; never touched from :napplet. private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } - // Per-applet sandboxed key-value store (namespaced by coordinate inside the impl). - private val storage by lazy { DataStoreNappletStorage(applicationContext) } + // Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl). + private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) } private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) } - // The broker for the current account, rebuilt only on account switch (see broker()). - private var cachedBroker: Pair? = null - - // Live relay subscriptions, keyed by the applet's subId; reads the current account live. - private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() } + // Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the + // requesting surface's launch token, so a surface's REQs always target the account it acts as. + private val liveSubscriptions = NappletLiveSubscriptions() // The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes. private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) } - // Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet. + // Streams identity.changed to a watching applet. Bound to the surface's LAUNCH account, not the + // app's active one: a surface acts as the account that opened it for its whole life, so switching + // accounts elsewhere is not an identity change *for it*. Announcing the newly-active pubkey here + // would tell a page it had become someone else while its signatures still came back as the + // original — the same desync the launch binding exists to prevent. What this does still report is + // that account going away (logout/removal), which emits "". private val identityWatch = - NappletIdentityWatch(scope) { - Amethyst.instance.sessionManager.accountContent - .map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" } + NappletIdentityWatch(scope) { boundPubKey -> + Amethyst.instance.accountsCache.accounts + .map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" } } // Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS. @@ -114,6 +118,13 @@ class NappletBrokerService : Service() { override fun onDestroy() { liveSubscriptions.closeAll() identityWatch.stop() + // Every applet/browser surface has unbound, so the "session" the user granted for is over. + // The ledger and the broker cache are now app-wide singletons that outlive this service, so + // their in-memory session grants have to be dropped explicitly here — that keeps the lifetime + // the consent dialog promises ("allow for this session") instead of letting it become + // "allow until the app process dies". + dropCachedBroker() + Amethyst.instance.nappletPermissionLedger.endSession() // Drop any foreground holds this broker still owns so they don't leak past the service. synchronized(foregroundLeases) { repeat(foregroundLeases.size) { SandboxForegroundHold.release() } @@ -241,7 +252,10 @@ class NappletBrokerService : Service() { val replyTo = msg.replyTo ?: return true val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin) - val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)) + // Bind to the account active at mint time: a browser token minted for one account must + // never sign as another if the user switches while the page is still open. + val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true + val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey) val response = Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { this.data = @@ -278,17 +292,20 @@ class NappletBrokerService : Service() { // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply // decision (it stays wire-identical with the future desktop host). This service only supplies // the broker, the Messenger transport, and the live relay subscription each Outcome implies. - val broker = broker() + // The launch token decides whose key signs — not the active account. A surface opened by + // one account can never be handed another's signer, even while it stays open across a switch. + val broker = brokerFor(session.accountPubKey) if (broker == null) { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in."))) + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) return@launch } when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { is NappletRequestRouter.Outcome.Ignore -> {} is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) - is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) } + is NappletRequestRouter.Outcome.OpenSubscription -> + liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) - is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) } + is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) } is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop() is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) @@ -327,28 +344,44 @@ class NappletBrokerService : Service() { } } + /** The launched-as account, or null once it is no longer loaded. */ + private fun accountFor(accountPubKey: HexKey): Account? = Amethyst.instance.accountsCache.accounts.value[accountPubKey] + /** - * The broker for the *currently* signed-in account, cached and rebuilt only when the account - * changes (reference identity). The gateways capture the account and read its flows live, so a - * cached broker stays correct across requests without per-request allocation. + * The broker for the account a surface was LAUNCHED as — [NappletLaunchRegistry.Session.accountPubKey], + * never whichever account is active right now. + * + * Resolving live was wrong in a way that defeated per-account isolation: a full-screen host is a + * separate activity that an account switch does not tear down, so its WebView kept account A's + * cookies while requests were signed by B. The page displayed one identity while another signed, + * and B's session was written into A's storage jar — after which even the embedded tab, which is + * rebuilt correctly, showed the wrong account. + * + * Binding to the launch account satisfies both halves of the rule with no extra machinery: + * embedded surfaces are torn down and re-minted on a switch, so they follow the active account, + * while a full-screen surface stays on the account it was opened with. + * + * Returns null when that account is no longer loaded (logged out), so requests fail closed + * rather than silently falling back to someone else's key. */ - @Synchronized - private fun broker(): NappletBroker? { - val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null - cachedBroker?.let { (acc, broker) -> if (acc === account) return broker } - val broker = - AccountNappletGateways( - account = account, - context = applicationContext, - ledger = ledger, - storage = storage, - // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes - // through Tor when asked + active, and falls back to clearnet otherwise. - httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, - signerLedger = signerLedger, - ).broker() - cachedBroker = account to broker - return broker + private fun brokerFor(accountPubKey: HexKey): NappletBroker? { + val account = accountFor(accountPubKey) ?: return null + synchronized(brokerLock) { + cachedBroker?.let { (acc, broker) -> if (acc === account) return broker } + val broker = + AccountNappletGateways( + account = account, + context = applicationContext, + ledger = ledger, + storage = storage, + // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes + // through Tor when asked + active, and falls back to clearnet otherwise. + httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, + signerLedger = signerLedger, + ).broker() + cachedBroker = account to broker + return broker + } } /** @@ -403,6 +436,38 @@ class NappletBrokerService : Service() { } companion object { + /** + * Guards [cachedBroker]. Both live on the companion rather than the service instance so the + * Connected Apps UI can reach the running broker to revoke its live session grants — the + * screens are plain composables with no binder to this service, and the broker is the only + * holder of the in-memory "allow for this session" signer grants. + * + * Main-process only, like the sibling `Napplet*Registry` objects: the `:napplet` process gets + * its own (unused, empty) copy of these statics and must never touch them. + */ + private val brokerLock = Any() + + // The broker for the current account, rebuilt only on account switch (see brokerFor()). + private var cachedBroker: Pair? = null + + /** + * Drops the live "allow for this session" signer grants the running broker holds for + * [coordinate] (the bare app coordinate). Called when the user revokes or forgets an app in + * Connected Apps: without it the persisted grants are cleared but the in-memory session ones + * keep authorizing signatures until the broker dies, so a revoked app goes on signing. + * + * No-op when no broker has been built yet (no applet has run this process). + */ + suspend fun revokeSessionGrants(coordinate: String) { + val broker = synchronized(brokerLock) { cachedBroker?.second } ?: return + broker.revokeSessionGrants(coordinate) + } + + /** Forgets the cached broker, dropping every session grant it holds. */ + private fun dropCachedBroker() { + synchronized(brokerLock) { cachedBroker = null } + } + /** * Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin, * carried in the identity's identifier (which the consent dialog shows); this constant only fills diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt index 94875be170..17c22829bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt @@ -23,15 +23,19 @@ package com.vitorpamplona.amethyst.napplet import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -41,11 +45,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.draw.clip import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -54,6 +65,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.theme.AmethystTheme /** @@ -168,20 +180,74 @@ private fun NappletConsentDialog( ) } - // Operation detail box (may include content preview) - if (info.operationSummary.isNotBlank()) { + // Operation detail box (may include content preview), plus the full event behind a + // toggle: the summary truncates content and cannot spell out every tag, so for kinds + // whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure. + if (info.operationSummary.isNotBlank() || info.rawData.isNotBlank()) { Spacer(Modifier.height(12.dp)) Surface( modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium, ) { - SelectionContainer { - Text( - info.operationSummary, - modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodySmall, - ) + Column(modifier = Modifier.padding(12.dp)) { + if (info.operationSummary.isNotBlank()) { + SelectionContainer { + Text( + info.operationSummary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + // A single-account follow/mute change: show who, so the user recognizes + // the face rather than parsing a name they may not read carefully. + info.subject?.let { subject -> + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + RobohashFallbackAsyncImage( + robot = subject.pubKey, + model = subject.pictureUrl, + contentDescription = subject.name, + modifier = Modifier.size(36.dp).clip(CircleShape), + loadProfilePicture = true, + loadRobohash = true, + ) + Spacer(Modifier.width(8.dp)) + Text( + subject.name, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + if (info.rawData.isNotBlank()) { + var showRawData by remember { mutableStateOf(false) } + if (showRawData) { + Spacer(Modifier.height(8.dp)) + Surface(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SelectionContainer { + Text( + info.rawData, + style = + MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + ) + } + } + } + TextButton(onClick = { showRawData = !showRawData }) { + Text( + if (showRawData) { + stringResource(R.string.napplet_consent_hide_event) + } else { + stringResource(R.string.napplet_consent_show_event) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt index e2a0daa355..c544bb1660 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt @@ -36,6 +36,26 @@ data class NappletConsentInfo( /** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */ val allowAlways: Boolean, val iconUrl: String? = null, + /** + * The full unsigned event the applet asked us to sign, pretty-printed, shown behind a + * "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy + * rendering — it truncates content and cannot spell out every tag — so this is the only place + * the user can see exactly what a signature would cover. + */ + val rawData: String = "", + /** + * The one account a follow/mute change is about, when the change names exactly one. Rendered as + * an avatar + name so the user can recognize *who* at a glance instead of reading a bare count. + * Null for multi-account edits and every other request. + */ + val subject: ConsentSubject? = null, +) + +/** A single account a consent dialog is about: enough to draw an avatar and a name. */ +data class ConsentSubject( + val pubKey: String, + val name: String, + val pictureUrl: String?, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index a65238c703..42f4ec93f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -21,22 +21,35 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip01Core.core.fastForEach +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent /** * Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog * shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview - * or a sat amount). Localized via app resources; holds only a [Context], no account state. + * or a sat amount). Localized via app resources. + * + * Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy + * already cached there, so the dialog can say what a signature would actually change. It never signs, + * mutates, or exposes account state — the values it reads are the user's own public lists. */ class NappletConsentSummary( private val context: Context, + private val account: Account, ) { fun info( identity: NappletIdentity, @@ -51,16 +64,196 @@ class NappletConsentSummary( } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } + val consequence = consequenceFor(request) return NappletConsentInfo( appletTitle = title, coordinate = identity.coordinate, capabilityLabel = context.getString(capability.labelRes()), - operationSummary = summaryFor(request), + operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"), allowAlways = capability.canGrantAlways, iconUrl = iconUrl, + rawData = rawEventFor(request), + subject = consequence?.subject, ) } + /** + * Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the + * signing requests carry one; everything else has nothing to disclose. + */ + private fun rawEventFor(request: NappletRequest): String = + when (request) { + is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null) + is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt) + else -> "" + } + + private fun rawEvent( + kind: Int, + tags: Array>, + content: String, + createdAt: Long?, + ): String = + buildString { + append("kind: ").append(kind).append('\n') + createdAt?.let { append("created_at: ").append(it).append('\n') } + append("tags:") + if (tags.isEmpty()) { + append(" []\n") + } else { + append('\n') + tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') } + } + append("content: ").append(content.ifEmpty { "(empty)" }) + } + + /** A consequence line, plus the single account it is about when the change names exactly one. */ + private data class Consequence( + val text: String, + val subject: ConsentSubject? = null, + ) + + /** + * A plain-language warning for the kinds whose payload lives entirely in the tags. Without this + * the dialog reads "publish a kind 3 event" while the user is actually about to replace their + * whole social graph — the summary would be technically true and practically useless. + */ + private fun consequenceFor(request: NappletRequest): Consequence? = + when (request) { + is NappletRequest.Publish -> consequenceFor(request.kind, request.tags) + is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags) + else -> null + } + + private fun consequenceFor( + kind: Int, + tags: Array>, + ): Consequence? = + when (kind) { + ContactListEvent.KIND -> + diffOf( + current = account.kind3FollowList.getFollowListEvent()?.tags, + proposed = tags, + tagName = "p", + template = R.string.napplet_consent_diff_follows, + added = R.plurals.napplet_consent_diff_follow_added, + removed = R.plurals.napplet_consent_diff_follow_removed, + oneAdded = R.string.napplet_consent_diff_follow_one, + oneRemoved = R.string.napplet_consent_diff_unfollow_one, + ) + AdvertisedRelayListEvent.KIND -> + diffOf( + current = account.nip65RelayList.getNIP65RelayList()?.tags, + proposed = tags, + tagName = "r", + template = R.string.napplet_consent_diff_relays, + added = R.plurals.napplet_consent_diff_relay_added, + removed = R.plurals.napplet_consent_diff_relay_removed, + ) + // Public entries only: a mute list also carries encrypted ones, which are not in `tags` + // and so cannot be diffed here. + MuteListEvent.KIND -> + diffOf( + current = account.muteList.getMuteList()?.tags, + proposed = tags, + tagName = "p", + template = R.string.napplet_consent_diff_mutes, + added = R.plurals.napplet_consent_diff_mute_added, + removed = R.plurals.napplet_consent_diff_mute_removed, + oneAdded = R.string.napplet_consent_diff_mute_one, + oneRemoved = R.string.napplet_consent_diff_unmute_one, + ) + // Deletions have no prior version to compare against — the tags are the whole request. + DeletionEvent.KIND -> + pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a")) + ?.let { Consequence(it) } + // Any other kind: at least tell the user tags exist and can be inspected, so an empty + // content preview never reads as "there is nothing else here". + else -> + if (tags.isNotEmpty()) { + pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) } + } else { + null + } + } + + /** + * Describes what a proposed replaceable list changes relative to the copy already on the account. + * A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list + * that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when + * nothing is cached to compare against. + */ + private fun diffOf( + current: Array>?, + proposed: Array>, + tagName: String, + @StringRes template: Int, + @PluralsRes added: Int, + @PluralsRes removed: Int, + @StringRes oneAdded: Int? = null, + @StringRes oneRemoved: Int? = null, + ): Consequence { + val next = valuesOf(proposed, tagName) + val previous = + current?.let { valuesOf(it, tagName) } + ?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size)) + + val addedKeys = next.filter { it !in previous } + val removedKeys = previous.filter { it !in next } + if (addedKeys.isEmpty() && removedKeys.isEmpty()) { + return Consequence(context.getString(R.string.napplet_consent_diff_none)) + } + + // The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one + // account is far more use than "follows 1 new account" — the user can tell at a glance + // whether it is who they expected. + if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) { + subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) } + } + if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) { + subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) } + } + + val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size)) + val summary = + if (parts.size == 2) { + context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1]) + } else { + parts.first() + } + return Consequence(context.getString(template, summary)) + } + + /** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */ + private fun subjectOf(pubKey: String): ConsentSubject? { + val user = account.cache.getUserIfExists(pubKey) ?: return null + return ConsentSubject( + pubKey = pubKey, + name = user.toBestDisplayName(), + pictureUrl = user.profilePicture(), + ) + } + + /** The distinct values of every `[tagName, value, …]` tag. */ + private fun valuesOf( + tags: Array>, + tagName: String, + ): Set { + val out = mutableSetOf() + tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) } + return out + } + + private fun pluralFor( + resId: Int, + count: Int, + ): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count) + + private fun countTag( + tags: Array>, + name: String, + ): Int = tags.count { it.isNotEmpty() && it[0] == name } + private fun summaryFor(request: NappletRequest): String = when (request) { is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey) @@ -91,11 +284,14 @@ class NappletConsentSummary( } is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify) is NappletRequest.PayInvoice -> { + // getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a + // naive read renders "pay 0 sats" — telling the user a payment is free when the amount + // is in fact unspecified and decided by the payee. Treat non-positive as "no amount". val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull() - if (sats != null) { + if (sats != null && sats > 0) { pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats) } else { - context.getString(R.string.napplet_consent_pay) + context.getString(R.string.napplet_consent_pay_no_amount) } } is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index 8fc713bf34..f94f7bc2cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -39,15 +39,18 @@ import kotlinx.coroutines.launch */ class NappletIdentityWatch( private val scope: CoroutineScope, - private val pubKey: () -> Flow, + private val pubKey: (boundPubKey: String) -> Flow, ) { private var job: Job? = null - fun start(push: (String) -> Unit) { + fun start( + boundPubKey: String, + push: (String) -> Unit, + ) { stop() job = scope.launch { - pubKey() + pubKey(boundPubKey) .distinctUntilChanged() .drop(1) .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index 2b619b39ae..b91aabad36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import java.security.SecureRandom @@ -45,6 +46,18 @@ object NappletLaunchRegistry { data class Session( val identity: NappletIdentity, val declared: Set, + /** + * The account this surface was launched as. Requests resolve their signer through THIS, not + * through whichever account happens to be active when they arrive. + * + * A full-screen host is a separate activity that an account switch does not tear down, so + * resolving live meant its WebView kept account A's cookies while the broker signed as B — + * a page showing one identity while another signed, and B's session written into A's + * storage jar. Binding here gives both halves of the rule for free: embedded surfaces are + * rebuilt on a switch, so they re-mint and follow the active account, while a full-screen + * surface keeps the account it was opened with. + */ + val accountPubKey: HexKey, ) // Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The @@ -60,9 +73,10 @@ object NappletLaunchRegistry { fun register( identity: NappletIdentity, declared: Set, + accountPubKey: HexKey, ): String { val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() - sessions[token] = Session(identity, declared) + sessions[token] = Session(identity, declared, accountPubKey) return token } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index d7e6d15f57..2c98ffb8fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -120,7 +120,16 @@ object NappletLauncher { // requests back to THIS identity + declared set, regardless of anything the sandbox sends. val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) val declared = profile.declaredCapabilities(requires) - val launchToken = NappletLaunchRegistry.register(identity, declared) + // Bound to the account launching it, so the surface keeps signing as that account even if the + // user switches while it is open (an embedded surface is rebuilt on a switch and re-mints). + // An empty key can never match a loaded account, so a launch with nobody signed in fails + // closed at the broker rather than falling back to whoever signs in later. + val launchAccountPubKey = + Amethyst.instance.sessionManager + .loggedInAccount() + ?.pubKey + .orEmpty() + val launchToken = NappletLaunchRegistry.register(identity, declared, launchAccountPubKey) // Resolve the per-site network choice (Tor default; a site can be opted out to the open web). // Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle. @@ -156,6 +165,9 @@ object NappletLauncher { putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name) putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor) putString(NappletHostContract.EXTRA_THEME, theme) + // Opaque per-account storage partition, so a napplet/nSite can't carry one npub's cookies + // and localStorage into another. Derived here (the sandbox never sees the pubkey). + putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 1115bd026e..8429a0b721 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -38,12 +38,13 @@ import java.util.concurrent.atomic.AtomicInteger * `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the * caller-supplied sink — it never touches the transport itself. * - * [account] is read live (so it always targets the currently signed-in account); [open] is reached - * only after the broker authorized the subscription (RELAY consent). + * The account is supplied per [open] by the caller, which resolves it from the requesting surface's + * LAUNCH account — not from whoever is signed in at the time. A full-screen surface survives an + * account switch, and reading live would have pointed its REQs at the new account's relays while its + * signatures still came from the old one. [open] is reached only after the broker authorized the + * subscription (RELAY consent). */ -class NappletLiveSubscriptions( - private val account: () -> Account?, -) { +class NappletLiveSubscriptions { private val liveSubs = ConcurrentHashMap() private val liveSeq = AtomicInteger(0) @@ -62,9 +63,9 @@ class NappletLiveSubscriptions( fun open( nappletSubId: String, filters: List, + account: Account?, push: (String) -> Unit, ) { - val account = account() val relays = account?.homeRelays?.flow?.value ?: emptySet() if (account == null || filters.isEmpty() || relays.isEmpty()) { push(NappletProtocolJson.encodeRelayEose(nappletSubId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletWebViewProfiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletWebViewProfiles.kt new file mode 100644 index 0000000000..05090f91ed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletWebViewProfiles.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.napplet + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import java.security.MessageDigest + +/** + * Mints the opaque per-account WebView storage-profile name the sandbox partitions cookies, + * localStorage, IndexedDB and service workers by. + * + * Every embedded app follows the currently-selected account, and each account gets its OWN storage + * jar: switching from A to B hands B a clean jar, switching back to A restores A's session intact + * (this is a partition, not a wipe). + * + * The name is a hash rather than the pubkey because the `:napplet` sandbox must never learn which + * account it is running for — it only gets a stable, meaningless token. Stability is what makes + * sessions survive a switch, so the derivation must never change once shipped. + * + * The applying half lives in `:nappletHost` (`NappletWebViewProfile`), which validates the shape + * before handing it to `ProfileStore`. + */ +object NappletWebViewProfiles { + /** Domain separator so this hash can never collide with another use of SHA-256(pubkey). */ + private const val DOMAIN = "amethyst-webview-profile-v1:" + + /** 128 bits of a SHA-256 is far past collision-proof for a handful of on-device accounts. */ + private const val NAME_LENGTH = 32 + + /** The profile name for the account embedded apps currently run as, or null when logged out. */ + fun current(): String? = forPubKey(Amethyst.instance.nappletAccountScope()) + + /** Stable profile name for [pubKey]; null for a blank scope (no account -> shared default jar). */ + fun forPubKey(pubKey: HexKey): String? { + if (pubKey.isBlank()) return null + + return MessageDigest + .getInstance("SHA-256") + .digest((DOMAIN + pubKey).toByteArray()) + .toHexKey() + .take(NAME_LENGTH) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 7d6932b6f6..3472835691 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -24,15 +24,19 @@ import android.content.Context import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.utils.TimeUtils /** Human-readable label for a [NostrSignerOp]. */ @@ -41,8 +45,24 @@ fun NostrSignerOp.label(context: Context): String = is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind) NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt) NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) + is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty)) } +/** + * A person's display name for a consent prompt: their profile name when we have it cached, otherwise + * a shortened npub. Never empty — "read your private messages with " would be worse than the + * broad wording it replaces. + */ +fun counterpartyLabel(pubKeyHex: HexKey): String { + LocalCache + .getUserIfExists(pubKeyHex) + ?.toBestDisplayName() + ?.ifBlank { null } + ?.let { return it } + val npub = runCatching { NPub.create(pubKeyHex) }.getOrNull() + return if (npub != null) npub.take(12) + "…" else pubKeyHex.take(12) + "…" +} + /** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */ fun buildSignerConsentInfo( context: Context, @@ -105,10 +125,16 @@ fun buildSignerConsentInfo( ) } -/** Creates a [SignerConnectInfo] for the first-connect dialog. */ +/** + * Creates a [SignerConnectInfo] for the first-connect dialog. [declared] is the capability set the + * connection pre-grants as ALLOW_ALWAYS on accept, so it is surfaced as + * [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to + * approve a set it never showed them. + */ fun buildConnectInfo( context: Context, identity: NappletIdentity, + declared: Set = emptySet(), ): SignerConnectInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) val (title, iconUrl) = @@ -124,5 +150,18 @@ fun buildConnectInfo( } else { identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } } - return SignerConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) + // Only the capabilities that actually get pre-granted are listed; SHELL/THEME never prompt and + // VALUE is per-use, so listing them would overstate what accepting hands over. + val preGranted = + declared + .filter { it.requiresConsent && !it.requiresPerUseConsent } + .map { context.getString(it.labelRes()) } + .sorted() + return SignerConnectInfo( + appletTitle = title, + coordinate = identity.coordinate, + domain = domain, + iconUrl = iconUrl, + requestedPermissions = preGranted, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index e73d3e89a3..db32cbbf6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -81,7 +81,9 @@ class AccountNappletGateways( private val httpClient: (useProxy: Boolean) -> OkHttpClient, private val signerLedger: NostrSignerPermissionLedger? = null, ) { - private val consentSummary = NappletConsentSummary(context) + // Takes the account so the consent dialog can diff a proposed replaceable list (follows, relays, + // mutes) against the copy already cached here, and say what actually changes. + private val consentSummary = NappletConsentSummary(context, account) // Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor // routing, Onion-Location discovery/rewriting, Blossom cache and pool as the @@ -138,10 +140,10 @@ class AccountNappletGateways( } val connectPrompt = - NostrConnectPrompt { identity -> + NostrConnectPrompt { identity, declared -> SignerConnectCoordinator.requestConnect( context = context, - info = buildConnectInfo(context, identity), + info = buildConnectInfo(context, identity, declared), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index d9d13813de..f570c480ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import okhttp3.OkHttpClient import kotlin.coroutines.cancellation.CancellationException @@ -45,14 +46,23 @@ import kotlin.coroutines.cancellation.CancellationException * it also picks up NUT-02 per-input fee handling for free. */ class MeltProcessor { + /** + * @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token + * pointing at one of those was, by definition, issued by a mint the user + * deliberately added, so it is exempt from the private-address block that + * [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies + * to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate). + */ suspend fun melt( token: CashuToken, lud16: String, okHttpClient: (String) -> OkHttpClient, context: Context, + knownWalletMints: Set = emptySet(), ): MeltResult { try { - val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient)) + val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) } + val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient)) val proofs = token.proofs // A Lightning address must commit to an amount before we know the @@ -106,6 +116,14 @@ class MeltProcessor { } catch (e: Exception) { if (e is CancellationException) throw e if (e is LightningAddressResolver.LightningAddressError) throw e + // The mint URL was refused before any request went out: this is OUR + // message, not the mint's, so don't dress it up as "the mint said". + if (e is MintUrlException) { + throw LightningAddressResolver.LightningAddressError( + stringRes(context, R.string.cashu_unsafe_mint_url), + stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message), + ) + } throw LightningAddressResolver.LightningAddressError( stringRes(context, R.string.cashu_failed_redemption), stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 33e6e67ebf..0008693396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -51,20 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { + // Backward-paging boundary: once the feed has filled a page, ask for everything older than + // its oldest card. It stays null until then — see the note on the missing week floor below, + // which is what let it stay null forever on a quiet inbox. + val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() + val inbox = key.account.notificationRelays.flow.value.flatMap { + // No `since` floor on the first fetch. These filters are scoped by `#p` to my own + // key and carry a relay-side `limit`, so an all-time query costs one index scan and + // returns at most `limit` events, newest first — exactly what Home does (it passes + // `since ?: boundary`, i.e. null on a cold start). + // + // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for + // anyone whose last mention was older than a week: EOSE `since` is in-memory only, + // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above + // could never rescue it — it only arms once the feed holds a full page, and the feed + // could not fill because the query only ever asked for a week. A fresh install of an + // established account hit the same deadlock. + val notificationSince = since?.get(it)?.time ?: pagingBoundary + filterSummaryNotificationsToPubkey( relay = it, pubkey = user(key).pubkeyHex, - since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), + since = notificationSince, ) + filterNotificationsToPubkey( relay = it, pubkey = user(key).pubkeyHex, - // Fixed one-week live tail. Everything older is paged on demand by the marker-driven - // [AccountNotificationsHistoryEoseManager] (until+limit, per relay) — the NIP-04 model — - // so this filter no longer drifts its `since` back as the feed fills. - since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), + since = notificationSince, ) } @@ -79,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager( relay = relay, pubkey = user(key).pubkeyHex, groupIds = groupIds.distinct(), - since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(), + // Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200` + // already bound this, so a week floor only hides older group activity. + since = since?.get(relay)?.time ?: pagingBoundary, ) } @@ -106,6 +122,11 @@ class AccountNotificationsEoseFromInboxRelaysManager( invalidateFilters() } }, + key.account.scope.launch(Dispatchers.IO) { + key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 3ada70531f..7cfc8cd385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -27,12 +27,12 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch class AccountNotificationsEoseFromRandomRelaysManager( @@ -50,9 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Fixed one-week live tail of stragglers from follow relays. Backward history is the marker-driven - // [AccountNotificationsHistoryEoseManager]'s job (on inbox + group relays), so this no longer drifts. - val defaultSince = TimeUtils.oneWeekAgo() + // only loads this after the feed is built, so it stays null on a quiet inbox. No week floor + // behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20 + // newest either way — the floor only ever hid notifications older than a week, and since the + // boundary above needs a full page to arm, a quiet inbox could never page past it. + val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { val since = since?.get(it)?.time ?: defaultSince filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) @@ -73,6 +75,11 @@ class AccountNotificationsEoseFromRandomRelaysManager( invalidateFilters() } }, + key.account.scope.launch(Dispatchers.IO) { + key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 1de97b9753..5deaa6928f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -40,28 +40,129 @@ import kotlinx.coroutines.withTimeoutOrNull * caller should surface that a lightning wallet is required. */ object BlossomPaymentHandler { + /** + * Hard ceiling on a single BUD-07 charge, in sats. + * + * BUD-07 charges are per-blob storage fees: real paid Blossom servers ask + * single-digit to low-hundreds of sats for a media upload. 10,000 sats is + * roughly USD 10 at a 100k BTC — one to two orders of magnitude above any + * legitimate per-blob fee, so it never gets in a real user's way, while + * capping what a hostile or compromised server can drain in one prompt. + * Anything above this is refused outright rather than shown to the user, + * because the value is server-chosen and a user tapping through a dialog is + * not a meaningful defence against a four-digit-sat surprise. + */ + const val MAX_PAYMENT_SATS = 10_000L + + /** Outcome of [pay]. Everything except [Paid] means no proof and no retry. */ + sealed interface PayResult { + data class Paid( + val proof: BlossomPaymentProof, + ) : PayResult + + /** The amount failed [checkAmount]; [reason] is user-facing. */ + data class Refused( + val reason: String, + ) : PayResult + + /** No invoice, no wallet, or the wallet request could not be sent. */ + data object Unavailable : PayResult + + /** The wallet never answered. The invoice stays blocked — see [InFlightInvoices]. */ + data object TimedOut : PayResult + } + + /** Verdict on the invoice amount, before any money moves. */ + sealed interface AmountCheck { + data class Ok( + val sats: Long, + ) : AmountCheck + + /** BOLT-11 with no amount: the payee picks it. Never payable unattended. */ + data object Amountless : AmountCheck + + data class OverCap( + val sats: Long, + ) : AmountCheck + + /** The invoice asks for something other than what the dialog told the user. */ + data class Mismatch( + val shownSats: Long?, + val actualSats: Long, + ) : AmountCheck + } + + /** + * Re-derives the amount from the invoice itself and checks it against both the + * cap and [shownSats] — the number the confirmation dialog put in front of the + * user. The amount shown must be the amount paid, so a server that swapped the + * invoice (or leaned on a misleading `X-Reason`) cannot get a different sum + * approved than the one the user agreed to. + */ + fun checkAmount( + payment: BlossomPaymentRequired, + shownSats: Long?, + ): AmountCheck { + val actual = amountSats(payment) ?: return AmountCheck.Amountless + if (actual > MAX_PAYMENT_SATS) return AmountCheck.OverCap(actual) + if (shownSats != actual) return AmountCheck.Mismatch(shownSats, actual) + return AmountCheck.Ok(actual) + } + + /** Human-readable refusal text for a non-[AmountCheck.Ok] verdict. */ + fun refusalReason(check: AmountCheck): String = + when (check) { + is AmountCheck.Ok -> "" + is AmountCheck.Amountless -> "The server's invoice does not state an amount. Amethyst will not pay it." + is AmountCheck.OverCap -> "The server asked for ${check.sats} sats, above the $MAX_PAYMENT_SATS sat limit for a media-server payment. Nothing was paid." + is AmountCheck.Mismatch -> + "The server's invoice is for ${check.actualSats} sats, not the ${check.shownSats ?: 0} sats shown. Nothing was paid." + } + /** True when this account has a wallet we can pay the lightning invoice with. */ fun canPay( account: Account, payment: BlossomPaymentRequired, ): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup() - /** The invoice amount in sats, for display in a confirmation prompt. */ + /** + * The invoice amount in sats for display in a confirmation prompt, or null when it is absent or + * unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare + * read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually + * unspecified and chosen by the payee. + */ fun amountSats(payment: BlossomPaymentRequired): Long? = payment.lightning?.let { - runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull() + runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 } } /** - * Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if - * there is no payable invoice, no wallet, or the wallet didn't confirm in time. + * Pays the challenge's BOLT-11 invoice via NWC and returns the proof. + * + * [shownSats] is what the confirmation dialog displayed; the invoice is + * re-read here and must match it and sit under [MAX_PAYMENT_SATS], otherwise + * nothing is sent to the wallet at all. */ suspend fun pay( account: Account, payment: BlossomPaymentRequired, - ): BlossomPaymentProof? { - val invoice = payment.lightning ?: return null - if (!account.nip47SignerState.hasWalletConnectSetup()) return null + shownSats: Long?, + ): PayResult { + val invoice = payment.lightning ?: return PayResult.Unavailable + if (!account.nip47SignerState.hasWalletConnectSetup()) return PayResult.Unavailable + + val check = checkAmount(payment, shownSats) + if (check !is AmountCheck.Ok) { + Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}") + return PayResult.Refused(refusalReason(check)) + } + + // Never send the same invoice twice: an earlier attempt may still settle. + if (!InFlightInvoices.tryClaim(invoice)) { + return PayResult.Refused( + "A payment for this invoice was already sent to your wallet and never confirmed. Amethyst will not pay it again.", + ) + } val preimageResult = CompletableDeferred() try { @@ -71,10 +172,26 @@ object BlossomPaymentHandler { } } catch (e: Exception) { Log.w("BlossomPayment", "Failed to send NWC payment request", e) - return null + // The request never left, so the invoice is definitively not in flight. + InFlightInvoices.release(invoice) + return PayResult.Unavailable } - val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null - return BlossomPaymentProof(lightningPreimage = preimage) + // NIP-47 offers no cancel for an outstanding pay_invoice, so a timeout + // cannot stop the payment — it can only stop us from sending it again. + // Deliberately do NOT release the claim on the timeout path. + val answered = withTimeoutOrNull(PAYMENT_TIMEOUT_MS) { preimageResult.await() } + if (answered == null && !preimageResult.isCompleted) return PayResult.TimedOut + + InFlightInvoices.release(invoice) + val preimage = answered ?: return PayResult.Unavailable + return PayResult.Paid(BlossomPaymentProof(lightningPreimage = preimage)) } + + /** + * How long we wait for the wallet. Matches the previous behaviour; note the + * NIP-47 filter itself is dropped after 60s, so a reply past 90s cannot reach + * us anyway — which is exactly why the invoice stays blocked afterwards. + */ + private const val PAYMENT_TIMEOUT_MS = 90_000L } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/InFlightInvoices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/InFlightInvoices.kt new file mode 100644 index 0000000000..0068ac7f48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/InFlightInvoices.kt @@ -0,0 +1,60 @@ +/* + * 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.uploads.blossom + +/** + * BOLT-11 invoices handed to the NIP-47 wallet whose fate we never learned. + * + * [BlossomPaymentHandler.pay] waits a bounded time for the wallet to reply, but + * NIP-47 has no "cancel this pay_invoice": the request is fire-and-forget, so + * giving up on the wait does **not** stop the payment. An invoice that settles a + * second after we time out still moved the user's money — and if we then let the + * user (or an automatic retry) send the very same invoice again, they pay twice. + * + * So: claim the invoice before sending it, and release the claim only when the + * wallet gives a definitive answer. A timed-out invoice stays claimed for the + * life of the process and can never be paid a second time from this app. + * + * This is the weaker half of the fix — a genuine cancel would be better, but the + * NIP-47 client offers none, so we settle for "never silently pay it twice". + */ +object InFlightInvoices { + private val claimed = mutableSetOf() + + /** + * Claims [invoice] for one payment attempt. Returns false when it was already + * claimed and never resolved — the caller must not send it again. + */ + fun tryClaim(invoice: String): Boolean = synchronized(claimed) { claimed.add(invoice) } + + /** The wallet gave a definitive answer (paid or explicitly failed): the claim can go. */ + fun release(invoice: String) { + synchronized(claimed) { claimed.remove(invoice) } + } + + /** True when [invoice] was sent to the wallet and never resolved. */ + fun isAwaiting(invoice: String): Boolean = synchronized(claimed) { invoice in claimed } + + /** Test-only reset. */ + internal fun clear() { + synchronized(claimed) { claimed.clear() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/PaymentPromptLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/PaymentPromptLedger.kt new file mode 100644 index 0000000000..4bad3251a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/PaymentPromptLedger.kt @@ -0,0 +1,52 @@ +/* + * 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.uploads.blossom + +/** + * Bounds how often one user action may raise a BUD-07 payment prompt. + * + * The mirror flow retries a target after paying it, and that retry catches every + * exception — including a second `BlossomPaymentException`. Left unbounded, a + * server that pockets the preimage and answers 402 again drives an indefinite + * pay-prompt cycle: pay → 402 → prompt → pay → 402 → … Each cycle is a real + * payment, so "the user can always tap cancel" is not an adequate answer. + * + * Rule: a given (blob, server) pair may prompt at most once per user-initiated + * action. [beginUserAction] resets the ledger; everything downstream of that tap + * — including the post-payment retry — goes through [shouldPrompt]. + */ +class PaymentPromptLedger { + private val prompted = mutableSetOf() + + /** The user tapped mirror/sync: a fresh budget of one prompt per target. */ + fun beginUserAction() { + synchronized(prompted) { prompted.clear() } + } + + /** + * True the first time this (blob, server) pair asks for payment in the current + * user action; false on every subsequent 402 from the same pair. + */ + fun shouldPrompt( + hash: String, + server: String, + ): Boolean = synchronized(prompted) { prompted.add("$hash|$server") } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index d474c5e45f..0aab8e2fce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -105,7 +106,7 @@ fun BlossomBlobManagerScreen( BlossomPaymentDialog( host = pending.targetHost, amountSats = pending.amountSats, - reason = pending.payment.reason, + reason = pending.payment.sanitizedReason(), onConfirm = { vm.confirmPendingPayment() }, onDismiss = { vm.cancelPendingPayment() }, ) @@ -419,13 +420,21 @@ private fun BlossomPaymentDialog( icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) }, title = { Text(stringRes(R.string.blossom_payment_title)) }, text = { - Text( - text = - listOfNotNull( - stringRes(R.string.blossom_payment_message, host), - reason, - ).joinToString("\n\n"), - ) + Column { + Text(text = stringRes(R.string.blossom_payment_message, host)) + // X-Reason is server-controlled: it is sanitized upstream and rendered + // here attributed to the server, in a dimmer italic, so it can never be + // mistaken for Amethyst's own wording (e.g. a fake "Pay 1 sat"). + reason?.let { + Spacer(Modifier.size(12.dp)) + Text( + text = stringRes(R.string.blossom_payment_server_says, host, it), + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } }, confirmButton = { FilledTonalButton(onClick = onConfirm) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt index d5abfefec0..7a6bc293e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler +import com.vitorpamplona.amethyst.service.uploads.blossom.PaymentPromptLedger import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -129,6 +130,13 @@ class BlossomBlobManagerViewModel : ViewModel() { private var resultCollectorStarted = false + /** + * Caps BUD-07 payment prompts at one per (blob, server) per user-initiated + * mirror, so a server that keeps replying 402 after being paid cannot drive an + * unbounded pay-prompt cycle. See [PaymentPromptLedger]. + */ + private val promptLedger = PaymentPromptLedger() + fun init(accountViewModel: AccountViewModel) { this.account = accountViewModel.account // Reflect the app-level sync sweep's per-server results onto the pills, so an @@ -299,8 +307,17 @@ class BlossomBlobManagerViewModel : ViewModel() { } } - /** BUD-04: mirror a blob to every server that doesn't have it; each pill spins then turns green. */ + /** + * BUD-04: mirror a blob to every server that doesn't have it; each pill spins + * then turns green. This is the user-initiated entry point, so it resets the + * "already asked for payment" ledger — see [promptedForPayment]. + */ fun mirrorToMissing(row: BlobRow) { + promptLedger.beginUserAction() + mirrorMissingTargets(row) + } + + private fun mirrorMissingTargets(row: BlobRow) { val source = row.url ?: return val targets = currentRow(row.hash)?.missingServers ?: row.missingServers if (targets.isEmpty()) return @@ -313,6 +330,14 @@ class BlossomBlobManagerViewModel : ViewModel() { } catch (e: BlossomPaymentException) { setServerState(row.hash, target, PresenceState.MISSING) if (BlossomPaymentHandler.canPay(account, e.payment)) { + // Bounded: a server that pockets the preimage and replies 402 + // again must not be able to spin up an endless pay-prompt + // cycle. One prompt per target per user-initiated mirror. + if (!promptLedger.shouldPrompt(row.hash, target)) { + Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting") + _error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice." + continue + } _pendingPayment.value = PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment)) return@launch @@ -369,12 +394,31 @@ class BlossomBlobManagerViewModel : ViewModel() { _pendingPayment.value = null viewModelScope.launch(Dispatchers.IO) { setServerState(pending.hash, pending.target, PresenceState.PENDING) - val proof = BlossomPaymentHandler.pay(account, pending.payment) - if (proof == null) { - setServerState(pending.hash, pending.target, PresenceState.MISSING) - _error.value = "Payment failed or was not confirmed by the wallet." - return@launch - } + // pending.amountSats is exactly what the dialog showed; pay() re-derives + // the amount from the invoice and refuses if the two disagree or the + // amount is above the cap. + val result = BlossomPaymentHandler.pay(account, pending.payment, pending.amountSats) + val proof = + when (result) { + is BlossomPaymentHandler.PayResult.Paid -> result.proof + is BlossomPaymentHandler.PayResult.Refused -> { + setServerState(pending.hash, pending.target, PresenceState.MISSING) + _error.value = result.reason + return@launch + } + BlossomPaymentHandler.PayResult.TimedOut -> { + setServerState(pending.hash, pending.target, PresenceState.MISSING) + _error.value = + "Your wallet did not confirm in time. If the payment does go through, retry the mirror — " + + "Amethyst will not send this invoice again." + return@launch + } + BlossomPaymentHandler.PayResult.Unavailable -> { + setServerState(pending.hash, pending.target, PresenceState.MISSING) + _error.value = "Payment failed or was not confirmed by the wallet." + return@launch + } + } try { mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof) setServerState(pending.hash, pending.target, PresenceState.PRESENT) @@ -382,8 +426,9 @@ class BlossomBlobManagerViewModel : ViewModel() { setServerState(pending.hash, pending.target, PresenceState.MISSING) Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e) } - // Continue with any remaining missing servers (which may prompt again). - currentRow(pending.hash)?.let { mirrorToMissing(it) } + // Continue with any remaining missing servers. Targets already prompted + // in this action (including this one) will NOT prompt again. + currentRow(pending.hash)?.let { mirrorMissingTargets(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index c577c5d412..6e08f8b3e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -88,40 +88,13 @@ fun ConcordInviteCard( onClick = { nav.nav(Route.ConcordInvite(linkText)) }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + ConcordInvitePreviewRow( + robotSeed = robotSeed, + title = title, + subtitle = stringRes(R.string.concord_invite_card_subtitle), + accountViewModel = accountViewModel, + autoPlayGif = autoPlayGif, ) { - RobohashFallbackAsyncImage( - robot = robotSeed, - model = null, - contentDescription = title, - modifier = - Modifier - .size(52.dp) - .clip(CircleShape) - .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape), - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = autoPlayGif, - ) - Column(Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringRes(R.string.concord_invite_card_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } SymbolIcon( symbol = MaterialSymbols.ChevronRight, contentDescription = stringRes(R.string.concord_invite_card_join), @@ -131,3 +104,57 @@ fun ConcordInviteCard( } } } + +/** + * The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and + * the deep-link consent screen, so both render an invite identically. Purely + * presentational — it performs no I/O, which is what lets the deep-link screen show a + * preview without contacting the link's (attacker-supplied) relays before the user + * consents. + */ +@Composable +fun ConcordInvitePreviewRow( + robotSeed: String, + title: String, + subtitle: String, + accountViewModel: AccountViewModel, + autoPlayGif: Boolean, + trailing: @Composable () -> Unit = {}, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = robotSeed, + model = null, + contentDescription = title, + modifier = + Modifier + .size(52.dp) + .clip(CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + trailing() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 53e8a2c3bc..439097b41a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -153,6 +153,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher @@ -334,6 +335,10 @@ fun AppNavigation( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems .collectAsStateWithLifecycle() + // Move every embedded app to the new account on a switch. Mounted before the layer and + // the preloader so the previous account's sessions are dropped ahead of the first sweep + // (an embed WebView's storage profile is fixed at construction, so it must be rebuilt). + EmbeddedTabAccountWatcher() EmbeddedTabLayer(bottomBarItems.favoriteIds()) // Warm every pinned tab at startup so the first tap is instant (content already local). EmbeddedTabPreloader(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt index 3a103691d8..30dd9450fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt @@ -100,7 +100,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint -import kotlin.math.abs /** Zoom the map animates to after a search hit or a "use my location" tap. */ private const val RECENTER_ZOOM = 14.0 @@ -125,15 +124,16 @@ private fun zoomForGeohashLength(length: Int): Double = else -> 17.5 } -/** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */ -private const val WORLD_CENTER_LAT = 20.0 -private const val WORLD_CENTER_LON = 0.0 - /** - * Minimum center shift (degrees) from the opening center that counts as a real pan, - * so an initial osmdroid layout-scroll at the opening center is not mistaken for a pick. + * Neutral starting center when the picker opens with no seed: a genuinely unnamed + * point in the mid-Atlantic, framing the Americas, Europe and Africa at [WORLD_ZOOM]. + * + * Deliberately NOT 20N/0E — that is inland Mali (it reverse-geocodes to Tessalit) and + * sits exactly on the prime meridian, where a sub-kilometre pan flips the geohash + * between the `e…` and `s…` halves of the world and looks like a broken readout. */ -private const val SELECT_MOVE_EPS = 0.0005 +private const val WORLD_CENTER_LAT = 20.0 +private const val WORLD_CENTER_LON = -30.0 /** Give up waiting for a GPS fix after this long so the button never spins forever. */ private const val GPS_FIX_TIMEOUT_MS = 20_000L @@ -201,15 +201,21 @@ fun GeohashLocationPickerContent( val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } } val seedLen = initialGeohash?.trim()?.length ?: 0 - // The map opens centered here. Without a seed there is no real selection yet — and - // osmdroid can emit an initial scroll at this exact center, which must NOT be treated - // as a pick (else the picker would auto-select the mid-Atlantic and enable Confirm). + // The map opens centered here. Without a seed there is no real selection yet. val initialLat = seed?.centerLat ?: WORLD_CENTER_LAT val initialLon = seed?.centerLon ?: WORLD_CENTER_LON var pickedLat by remember { mutableStateOf(seed?.centerLat) } var pickedLon by remember { mutableStateOf(seed?.centerLon) } var hasSelection by remember { mutableStateOf(seed != null) } + + // osmdroid emits a scroll event when the MapView is first laid out, reporting a + // pixel-quantized version of the opening center — at world zoom a single pixel is + // ~0.4 degrees, so that phantom "pan" can be hundreds of km away from where we asked + // it to open. Treating it as a pick auto-selected whatever the default center was and + // enabled Confirm with nothing chosen. Only map movement that follows a real finger + // down on the map counts, so an automatic scroll can never become a selection. + var mapTouched by remember { mutableStateOf(false) } var level by remember { mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY) } @@ -337,8 +343,8 @@ fun GeohashLocationPickerContent( Column(modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth().weight(1f)) { LocationPickerMap( - latitude = seed?.centerLat ?: 20.0, - longitude = seed?.centerLon ?: 0.0, + latitude = initialLat, + longitude = initialLon, pickedLatitude = null, pickedLongitude = null, zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM, @@ -347,11 +353,12 @@ fun GeohashLocationPickerContent( zoomTo = zoomTo, highlight = highlight, highlightColor = highlightColor, + onUserInteraction = { mapTouched = true }, onCenterChanged = { lat, lon -> - pickedLat = lat - pickedLon = lon - // A pan/zoom away from the opening center is the user's first real pick. - if (!hasSelection && (abs(lat - initialLat) > SELECT_MOVE_EPS || abs(lon - initialLon) > SELECT_MOVE_EPS)) { + // Only a movement the user drove counts as their first real pick. + if (mapTouched) { + pickedLat = lat + pickedLon = lon hasSelection = true } }, @@ -687,13 +694,24 @@ private fun PickerBottomBar( } } Column(Modifier.weight(1f).padding(start = 12.dp)) { - LoadCityName(geohashStr = settledCell ?: cell) { cityName -> - Text( - cityName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - ) + // The place name must never contradict the geohash under it. Resolve + // it only for the settled cell, and only while that IS the current + // cell — mid-pan the debounced [settledCell] still names the previous + // cell, and drawing it beside a fresh geohash is worse than no name. + // LoadCityName echoes the geohash back when it cannot resolve a name + // (no geocoder backend, or a point at sea); drop that too rather than + // repeat the geohash as if it were a place. + if (settledCell == cell) { + LoadCityName(geohashStr = cell) { cityName -> + if (cityName != cell) { + Text( + cityName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + } } Text( "#$cell", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt index f5e3375398..77de813f92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt @@ -65,6 +65,10 @@ import org.osmdroid.views.overlay.Polygon * - [recenter] animates the map to a new point when its value changes (e.g. after * a place search or a "use my location" tap). Passing the same value twice is a * no-op, so it is safe to hoist in state. + * - [onUserInteraction] fires when a finger first lands on the map. [onCenterChanged] + * alone cannot tell a user pan from osmdroid's own layout-time scroll (which the + * MapView emits at the opening center with pixel-quantized coordinates), so callers + * that must not treat an automatic scroll as a choice gate on this instead. */ @Composable fun LocationPickerMap( @@ -80,12 +84,14 @@ fun LocationPickerMap( highlight: BoundingBox? = null, highlightColor: Int = 0, onCenterChanged: ((Double, Double) -> Unit)? = null, + onUserInteraction: (() -> Unit)? = null, onPick: (Double, Double) -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val currentOnPick by rememberUpdatedState(onPick) val currentOnCenterChanged by rememberUpdatedState(onCenterChanged) + val currentOnUserInteraction by rememberUpdatedState(onUserInteraction) val darkTheme = !MaterialTheme.colorScheme.isLight // Tracks the last point we animated to, so a recomposition that re-supplies the @@ -117,7 +123,10 @@ fun LocationPickerMap( // LocationPreviewMap. Returning false lets the MapView still pan/zoom/tap. setOnTouchListener { view, event -> when (event.action) { - MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_DOWN -> { + view.parent?.requestDisallowInterceptTouchEvent(true) + currentOnUserInteraction?.invoke() + } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false) } false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 76858b0776..4cc64d396a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -110,12 +110,16 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { - // NIP-05 resolution: user@domain or bare .bit domain + // NIP-05 resolution: full `name@domain` form, or bare + // `.bit` domain synthesised as the wildcard `_@domain`. + // Bare DNS domains aren't accepted here on purpose: a + // `.com`/`.io`/etc. that happens to host nostr.json is + // ambiguous with a regular URL the user might be typing. val nip05 = - if (prefix.contains('@')) { + if (prefix.endsWith(".bit", ignoreCase = true) && !prefix.contains('@')) { + Nip05Id.parseLenient(prefix) + } else if (prefix.contains('@')) { Nip05Id.parse(prefix) - } else if (prefix.endsWith(".bit", ignoreCase = true)) { - Nip05Id("_", prefix.lowercase()) } else { null } @@ -231,7 +235,7 @@ class UserSuggestionState( item: User, ): TextFieldValue { val lastWordStart = message.selection.end - word.length - val wordToInsert = "@${item.pubkeyNpub()} " + val wordToInsert = mentionInsertion(word, item) return TextFieldValue( message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert), @@ -244,11 +248,43 @@ class UserSuggestionState( word: String, item: User, ) { - val wordToInsert = "@${item.pubkeyNpub()} " + val wordToInsert = mentionInsertion(word, item) state.edit { val lastWordStart = selection.end - word.length replace(lastWordStart, selection.end, wordToInsert) selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) } } + + /** + * The token to insert into the message text when the author picks [item] + * from the suggestion popover. When the author was typing a NIP-05 + * mention (full `m@testls.bit` or bare-domain `.bit` form), we insert + * `nostr:nprofile1…` directly so the send-time tagger doesn't need to + * re-resolve anything — it parses the bech32 inline via its existing + * `nprofile1` branch, with no main-thread I/O. For every other path + * (search by name, typed npub/nprofile, hex) we keep the existing + * `@npub1…` form to preserve current behaviour. + * + * Pre-resolved NIP-05 hits already have their relay hints pushed into + * the account cache by [nip05ResolutionFlow] before this runs, so + * [User.toNProfile] picks them up automatically. + */ + private fun mentionInsertion( + word: String, + item: User, + ): String { + val typed = userSearchTermOrNull(word) + val wasNip05Mention = + typed != null && + ( + (typed.endsWith(".bit", ignoreCase = true) && !typed.contains('@')) || + (typed.contains('@') && Nip05Id.parse(typed) != null) + ) + return if (wasNip05Mention) { + "nostr:${item.toNProfile()} " + } else { + "@${item.pubkeyNpub()} " + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 77b25a66ca..27c05b30bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -95,8 +95,17 @@ fun RenderPodcastEpisode( val value = remember(noteEvent) { episode.episodeValue() } var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) } // Suppress the markdown block if blank — title + description already describe a short - // episode. Otherwise hand off to RichText below. - val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } } + // episode — and ALSO when it merely repeats the description. Most feeds put the same text in + // both the `description` tag and the event content, and the thread view renders both blocks + // (`makeItShort` is false there), so the whole description appeared twice inside one card, + // each copy with its own "Show More". Compared on collapsed whitespace so a copy differing + // only in wrapping still counts as a duplicate. + val markdown = + remember(noteEvent, description) { + noteEvent.content.ifBlank { null }?.takeUnless { body -> + description?.let { normalizeForCompare(body) == normalizeForCompare(it) } == true + } + } Column(MaterialTheme.colorScheme.replyModifier) { PodcastCoverCard(image, note, accountViewModel) @@ -230,3 +239,8 @@ fun RenderPodcastEpisode( } } } + +/** Collapses whitespace so two copies of the same text that differ only in wrapping compare equal. */ +private fun normalizeForCompare(text: String): String = text.trim().replace(WHITESPACE_RUN, " ") + +private val WHITESPACE_RUN = Regex("\\s+") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 9d27b2b332..46d4b9bb70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -379,6 +379,14 @@ class AccountSessionManager( Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup") return@launch } + // TODO: also drop this account's WebView storage profile — the cookies/localStorage of every + // site it visited survive here, keyed by NappletWebViewProfiles.forPubKey(hex). It CANNOT be + // done from this process: WebView profiles live in the WebView data directory, which belongs + // to the `:napplet` process (nothing calls setDataDirectorySuffix, so booting WebView here + // too would collide on the same directory). Deleting it needs a broker message that has + // `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in + // use by a live WebView. Not wired for this release; there is no existing hook that reaches + // the sandbox on account deletion. if (accountInfo.npub == currentAccountNPub()) { // Drop the Nest bridge ref before tearing down the // current account so the audio-room activity can't diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 143ba838c4..794915bb44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -632,6 +632,24 @@ class AccountViewModel( if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member) } + /** + * Set [member]'s CORD-04 roles in [communityId] to exactly [roleIds] (empty revokes + * everything). The Control Plane grant REPLACES the member's role set rather than + * merging into it, so [roleIds] must be the *complete* list the member should end up + * holding — the caller (the Members roster dialog) preselects their current roles for + * that reason. Authority is re-checked at fold time by every client, so the caller must + * also have offered only roles it strictly outranks on a member it strictly outranks. + */ + fun setConcordRoles( + communityId: String, + member: HexKey, + roleIds: List, + ) = launchSigner { + if (!account.grantConcordRole(communityId, member, roleIds)) { + toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed) + } + } + /** Ban/unban [member] from [communityId] (from the Members roster). */ fun setConcordBan( communityId: String, @@ -1580,6 +1598,15 @@ class AccountViewModel( fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + /** + * Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the + * signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the + * new list event is best-effort published to our outbox. Nothing here waits on a relay, which is + * what makes leaving a community whose own relays are dead work at all — the list lives in *our* + * outbox, not in the community's relays. + */ + fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) } + fun createRelayGroup( relay: NormalizedRelayUrl, groupId: String, @@ -2380,7 +2407,18 @@ class AccountViewModel( if (lud16 != null) { viewModelScope.launch(Dispatchers.IO) { try { - val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context) + val meltResult = + MeltProcessor().melt( + token, + lud16, + httpClientBuilder::okHttpClientForMoney, + context, + // Mints the user deliberately added are exempt from the + // private-address block (self-hosted LAN mints are legit). + knownWalletMints = + account.cashuWalletState.mints.value + .toSet(), + ) onDone( stringRes(context, R.string.cashu_successful_redemption), stringRes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 33a49a3cc6..84976ecaec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry @@ -73,6 +74,13 @@ class EmbeddedWebAppController( private var sandboxedSdkView: SandboxedSdkView? = null private var pendingAdapter: SandboxedUiAdapter? = null + + /** + * True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can + * only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the + * sandbox destroys its WebView, so the adapter is dead. See [attachView] for why this matters. + */ + private var adapterDelivered = false private var startUrl: String = "about:blank" private var hasLoadedReal = false @@ -92,7 +100,9 @@ class EmbeddedWebAppController( // A single NappletBrowserService instance serves every embedded browser tab, so each controller // stamps its own id on every message; the provider uses it to route controls/updates to this tab. - private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" + // Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the + // previous view can never reap the replacement. + private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" /** Invoked on the main thread when the page navigates: (url, canGoBack). */ var onUrlChanged: ((String, Boolean) -> Unit)? = null @@ -132,6 +142,7 @@ class EmbeddedWebAppController( serviceMessenger = null sandboxedSdkView = null pendingAdapter = null + adapterDelivered = false onUrlChanged = null onImeEvent = null onMagnifierFrame = null @@ -141,15 +152,48 @@ class EmbeddedWebAppController( override fun teardown() = unbind() - /** Hands the surface view to the controller; applies the adapter if it already arrived. */ + /** + * Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the + * remote session when this controller is being re-used by a *second* view. + * + * A warm controller outlives the composition (it lives in the process-scoped [EmbeddedTabHost]), but its + * [SandboxedSdkView] does not: an account switch rebuilds the whole logged-in subtree, disposing every + * surface. That disposal makes privacysandbox close the remote session, which destroys the sandbox's + * WebView — so the adapter this controller already handed out is dead and cannot be given to the fresh + * view. A [SandboxedSdkView] with no adapter never builds a ContentView/SurfaceView and paints nothing + * but its background colour, forever (the load overlay's retry can't help — it re-navigates a WebView + * that no longer exists). + * + * So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new + * session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms this view. The sandbox stamps the + * CURRENT account's storage profile on that new session (see [sendCreateSession]), so re-arming can + * never resurrect the previous account's cookie jar. + */ override fun attachView(view: SandboxedSdkView) { sandboxedSdkView = view // Paint the surface placeholder in the app's theme background so there's no white flash before // the remote WebView delivers its first frame. view.setBackgroundColor(backgroundColor) - pendingAdapter?.let { - view.setAdapter(it) - pendingAdapter = null + val adapter = pendingAdapter + when { + adapter != null -> { + pendingAdapter = null + adapterDelivered = true + view.setAdapter(adapter) + } + // No adapter in hand and one was already spent on a previous (now disposed) view: the session + // behind it is gone, so this view would stay blank forever. Re-create it. + adapterDelivered -> { + // Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox + // asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s + // AFTER this create: reusing the id let that late close reap the session we had just asked + // for — a new WebView was built, destroyed, and the surface stayed black. A new id makes + // the stale close target only the corpse it belongs to. + sessionId = "browser-${SESSION_SEQ.incrementAndGet()}" + adapterDelivered = false + sendCreateSession() + } + // else: the first session is still in flight; MSG_SESSION_READY will arm this view. } } @@ -165,6 +209,9 @@ class EmbeddedWebAppController( putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor) putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor) putString(NappletBrowserContract.KEY_THEME, themeType) + // Opaque per-account storage partition, so an embedded site can't carry one + // npub's session into another. Derived here (the sandbox never sees the pubkey). + putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) } } runCatching { serviceMessenger?.send(msg) } @@ -176,7 +223,12 @@ class EmbeddedWebAppController( val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo) val view = sandboxedSdkView - if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter + if (view != null) { + adapterDelivered = true + view.setAdapter(adapter) + } else { + pendingAdapter = adapter + } } NappletBrowserContract.MSG_URL_CHANGED -> { val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 8265fa2eff..de2beb64aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -116,7 +116,7 @@ private fun EmbeddedWebAppTab( // Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this // re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction). val controller = - remember(id, EmbeddedTabHost.themeEpoch) { + remember(id, EmbeddedTabHost.rebuildEpoch) { EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index d00d3120de..d585147fd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -233,6 +233,24 @@ fun ChatFeedLoaded( } Column(modifier = itemModifier) { + // A day/subject header belongs ABOVE the message it introduces. `reverseLayout` + // flips the order of the lazy list's items, but NOT the content inside one item: + // this Column still lays out top-to-bottom, so the divisor must be composed + // before the bubble. Composing it after put the header below its own message — + // i.e. visually heading the NEXT (newer) message while showing this one's date, + // which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble. + NewDateOrSubjectDivisor(older, item) + + // Per-relay paging markers for the gap toward the next-older message. Older items sit + // ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble — + // which means these belong before it, for the same reason the divisor does. Composed + // after the bubble they rendered in the gap toward the NEWER message, contradicting the + // bounds they are handed. + markersInGap?.invoke( + item.event?.createdAt, + older?.event?.createdAt, + ) + ChatroomMessageCompose( baseNote = item, routeForLastRead = routeForLastRead, @@ -246,19 +264,6 @@ fun ChatFeedLoaded( groupPosition = watchChatGroupPosition(newer, item, older), previousNoteId = older?.idHex, ) - - NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) - - // Per-relay paging markers belonging in the gap toward the next-older message. With the - // reverse layout this draws just above the message (the older side), so a relay's marker - // appears right below the oldest message it has reached and slides down as it pages. - markersInGap?.invoke( - item.event?.createdAt, - items.list - .getOrNull(index + 1) - ?.event - ?.createdAt, - ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 3aebe57917..6c54aa8afc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -126,6 +126,41 @@ fun ConcordChannelListScreen( var inviteLink by remember { mutableStateOf(null) } var minting by remember { mutableStateOf(false) } + // Prefer the folded metadata name, then the stored community name from the list entry (always + // present from the join/create — this is what shows everywhere else). Fall back to the app name + // only if neither exists (should be unreachable), never as the normal "metadata hasn't folded + // yet" placeholder — that showed "Amy Debug". + val communityName = + state?.metadata?.name + ?: session?.entry?.name?.ifBlank { null } + ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name) + + // Owner from the list entry, not the folded authority: a community whose relays are dead never + // folds a Control Plane, and that is exactly the case where leaving matters most. + val isOwner = session?.entry?.owner == account.signer.pubKey + + // Read once here (it is @Composable) so the post-leave navigation can use it from a callback. + val canPop = nav.canPop() + var showLeave by remember { mutableStateOf(false) } + + if (showLeave) { + ConcordLeaveDialog( + communityName = communityName, + isOwner = isOwner, + onDismiss = { showLeave = false }, + onConfirm = { + showLeave = false + // Fire-and-forget: the list edit is local + a best-effort publish to our own outbox, + // so we never hold the user behind a spinner waiting on a relay that may be dead. + accountViewModel.leaveConcordCommunity(communityId) + // Don't strand the user on the server view of a community they just left. Popping is + // right when we were pushed here; when this community is a bottom-nav root there is + // nothing to pop, so restart the stack on the Concord hub. + if (canPop) nav.popBack() else nav.newStack(Route.Concords) + }, + ) + } + // Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the // fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer. val canManageChannels = @@ -185,20 +220,10 @@ fun ConcordChannelListScreen( Scaffold( topBar = { TopAppBar( - title = { - // Prefer the folded metadata name, then the stored community name from the list - // entry (always present from the join/create — this is what shows everywhere else). - // Fall back to the app name only if neither exists (should be unreachable), never as - // the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug". - val title = - state?.metadata?.name - ?: session?.entry?.name?.ifBlank { null } - ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name) - Text(title, maxLines = 1) - }, + title = { Text(communityName, maxLines = 1) }, navigationIcon = { // Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place. - if (nav.canPop()) { + if (canPop) { IconButton(onClick = { nav.popBack() }) { SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) } @@ -236,6 +261,27 @@ fun ConcordChannelListScreen( ) { SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) } + + // Overflow, mirroring the NIP-29 relay-group top bar: destructive membership + // actions live behind the menu, never as a one-tap icon. + var menuOpen by remember { mutableStateOf(false) } + IconButton(onClick = { menuOpen = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_community), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuOpen = false + showLeave = true + }, + ) + } }, ) }, @@ -472,6 +518,50 @@ private fun rememberConcordDisplayName( return name } +/** + * Confirms leaving a community. Deliberately explicit about the blast radius: leaving is a private + * edit of *this account's* kind-13302 list — nobody is told, no roster changes — but it also drops + * the entry that carries the community's keys, so history this account can no longer derive may be + * gone for good. The owner gets an extra line: the entry is the only place their owner salt lives, + * so leaving is what actually retires the community for them. + */ +@Composable +private fun ConcordLeaveDialog( + communityName: String, + isOwner: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_message, communityName)) + if (isOwner) { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_owner_warning), + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.leave), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) + } + }, + ) +} + /** A pending channel create ([channelIdHex] null) or rename target. */ private data class ConcordChannelEditor( val channelIdHex: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 42748cc684..d66aef1608 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,13 +40,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.model.ConcordInviteResult +import com.vitorpamplona.amethyst.ui.components.ConcordInvitePreviewRow import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink private sealed interface RedeemState { + /** Showing the local preview, waiting for the user to tap Join. Nothing has been sent. */ + data object AwaitingConsent : RedeemState + data object Working : RedeemState data class Done( @@ -63,10 +73,25 @@ private sealed interface RedeemState { } /** - * Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]). - * On open it fetches + unlocks the bundle, joins the community, and forwards to its - * channel list. On failure it offers a retry, so a transient relay miss doesn't - * strand the user. + * Redeems a Concord invite link (deep-link target for [Route.ConcordInvite]). + * + * **This screen must never act before the user consents.** It is reachable from any + * `https://amethyst.social/invite/…` link on any web page, in any QR code, or in a + * push — i.e. from a URL the user may never have meant to open. Redeeming is a + * side-effecting act: it connects to up to three relay URLs *chosen by whoever minted + * the link* (disclosing the user's IP to them), publishes a Guestbook JOIN signed by + * the user's own identity to those relays, and writes the community into the user's + * private kind-13302 list. Doing that on arrival turned any link into a one-click + * deanonymize-and-enroll primitive, so the screen now opens on a local-only preview + * and only calls [com.vitorpamplona.amethyst.model.Account.joinConcordViaInvite] from + * the Join button. + * + * Everything shown before that tap comes from decoding the URL itself + * ([ConcordActions.parseInviteLink] — pure base64 + NIP-19, no I/O): the link's + * signer key and the bootstrap relays it would contact. The community's *name* lives + * inside the kind-33301 bundle, which only those relays can serve, so it is + * deliberately left unknown rather than fetched — fetching it is precisely the IP + * disclosure this screen exists to gate. */ @Composable fun ConcordInviteScreen( @@ -74,7 +99,19 @@ fun ConcordInviteScreen( accountViewModel: AccountViewModel, nav: INav, ) { - var state by remember(link) { mutableStateOf(RedeemState.Working) } + // Local decode only: base64 fragment + NIP-19 naddr. No relay is contacted here. + val parsed = remember(link) { ConcordActions.parseInviteLink(link) } + + var state by + remember(link) { + mutableStateOf( + if (parsed == null) { + RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false) + } else { + RedeemState.AwaitingConsent + }, + ) + } LaunchedEffect(link, state) { if (state is RedeemState.Working) { @@ -82,13 +119,15 @@ fun ConcordInviteScreen( when (val result = accountViewModel.account.joinConcordViaInvite(link)) { is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId) is ConcordInviteResult.InvalidLink -> - RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false) + RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false) is ConcordInviteResult.Incompatible -> - RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false) + RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false) is ConcordInviteResult.Revoked -> - RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false) + RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false) + is ConcordInviteResult.Expired -> + RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false) is ConcordInviteResult.NotReachable -> - RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true) + RedeemState.Failed(R.string.concord_invite_failed, canRetry = true) } } } @@ -96,8 +135,8 @@ fun ConcordInviteScreen( LaunchedEffect(state) { (state as? RedeemState.Done)?.let { done -> // Replace this invite screen with the community, dropping it from the back stack. If it - // stayed, Back from the community would land on the auto-redeeming spinner, which would - // immediately re-join and forward here again — trapping the user in a Back→forward loop. + // stayed, Back from the community would land on a consent screen for a community the + // user has already joined — a dead end offering to re-do what just happened. nav.popUpTo(Route.ConcordServer(done.communityId), Route.ConcordInvite::class) } } @@ -108,10 +147,19 @@ fun ConcordInviteScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { when (state) { + is RedeemState.AwaitingConsent -> + parsed?.let { + ConcordInviteConsent( + parsed = it, + accountViewModel = accountViewModel, + onJoin = { state = RedeemState.Working }, + ) + } + is RedeemState.Working -> { CircularProgressIndicator() Text( - stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite), + stringRes(R.string.concord_redeeming_invite), modifier = Modifier.padding(top = 16.dp), textAlign = TextAlign.Center, ) @@ -129,7 +177,7 @@ fun ConcordInviteScreen( onClick = { state = RedeemState.Working }, modifier = Modifier.padding(top = 16.dp), ) { - Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + Text(stringRes(R.string.retry)) } } } @@ -138,3 +186,55 @@ fun ConcordInviteScreen( } } } + +/** + * The pre-consent preview. Renders only what the URL itself decodes to — the link + * signer (used as the avatar seed) and the bootstrap relays the join would contact — + * plus a plain-language statement of what tapping Join will do. It performs **no** + * network I/O: the community name would require fetching the bundle from those very + * relays, which is the IP disclosure the consent gate exists to prevent, so it shows + * an explicit "name unknown until you join" instead. + */ +@Composable +private fun ConcordInviteConsent( + parsed: ParsedInviteLink, + accountViewModel: AccountViewModel, + onJoin: () -> Unit, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val relayList = remember(parsed) { parsed.fragment.relays.joinToString(", ") } + + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + ConcordInvitePreviewRow( + robotSeed = parsed.linkSignerPubKey, + title = stringRes(R.string.concord_invite_card_subtitle), + subtitle = stringRes(R.string.concord_invite_preview_unknown_name), + accountViewModel = accountViewModel, + autoPlayGif = autoPlayGif, + ) + } + + Text( + stringRes(R.string.concord_invite_preview_explainer), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 20.dp), + ) + + if (relayList.isNotEmpty()) { + Text( + stringRes(R.string.concord_invite_preview_relays, relayList), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + ) + } + + Button( + onClick = onJoin, + modifier = Modifier.padding(top = 24.dp), + ) { + Text(stringRes(R.string.concord_invite_card_join)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index feb28e6c4b..d2a7b390be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +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.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,6 +46,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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 @@ -124,12 +128,28 @@ fun ConcordMembersScreen( .minByOrNull { r -> r.position } ?.name ?.takeIf { n -> n.isNotBlank() } - RosterEntry(it, ConcordMembership.of(authority, it), roleName) + RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it)) }.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) } val iAmOwner = state?.authority?.isOwner(myPubKey) == true val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true + + // The roles this viewer may actually hand out. The fold drops a grant whose granter does + // not *strictly* outrank every assigned role, so offering a role at or above our own + // position would publish an edition that every client then silently discards. The owner + // sits at rank 0 and no role may claim position 0, so this admits everything for them. + val assignableRoles = + remember(state, myPubKey) { + val authority = state?.authority ?: return@remember emptyList() + val myRank = authority.rank(myPubKey) ?: return@remember emptyList() + authority + .roles() + .filter { (_, role) -> myRank < role.position } + .map { (id, role) -> AssignableRole(id, role.name, role.position) } + .sortedBy { it.position } + } Scaffold( topBar = { @@ -168,6 +188,19 @@ fun ConcordMembersScreen( isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true), viewerIsOwner = iAmOwner, viewerCanBan = iCanBan, + // Ban/Remove are rank-gated the same way Roles… is. The owner short-circuits + // because canActOn begins at hasPermission, which is false while banned, and + // a rogue BAN holder can currently banlist the owner — see the note on + // Account.concordBanTarget. + canBanTarget = + iAmOwner || + state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.BAN) == true, + viewerCanManageRoles = iCanManageRoles, + // canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not + // banned, the target isn't the owner (unremovable), and we strictly outrank + // them — which also rules out acting on ourselves (equal cannot act on equal). + canManageRolesOnTarget = state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.MANAGE_ROLES) == true, + assignableRoles = assignableRoles, accountViewModel = accountViewModel, nav = nav, ) @@ -185,6 +218,10 @@ private fun ConcordMemberRow( isSelf: Boolean, viewerIsOwner: Boolean, viewerCanBan: Boolean, + canBanTarget: Boolean, + viewerCanManageRoles: Boolean, + canManageRolesOnTarget: Boolean, + assignableRoles: List, accountViewModel: AccountViewModel, nav: INav, ) { @@ -193,13 +230,38 @@ private fun ConcordMemberRow( val isBanned = entry.membership == ConcordMembership.BANNED val isAdmin = entry.membership == ConcordMembership.ADMIN - // Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders, - // never against the owner or yourself. A banned user only offers "unban". + // Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders that + // strictly outrank the target, never against the owner or yourself. A banned user only offers + // "unban" — and unban is rank-gated too, so whoever cannot ban you cannot lift your ban either. val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf - val canBan = viewerCanBan && !isOwnerTarget && !isSelf + val canBan = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf // Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban. - val canRemove = viewerCanBan && !isOwnerTarget && !isSelf - val hasMenu = canToggleAdmin || canBan || canRemove + val canRemove = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf + // Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular + // member (or every defined role) is out of our reach — a grant we don't outrank + // publishes fine and is then dropped by every client's fold, so a silently no-op + // control would be worse than none. The owner's own row never offers it: the owner + // is unremovable and outranks everyone, so canManageRolesOnTarget is false there. + val rolesBlockedReason = + when { + !canManageRolesOnTarget -> stringRes(R.string.concord_members_roles_out_of_reach) + assignableRoles.isEmpty() -> stringRes(R.string.concord_members_roles_none_assignable) + else -> null + } + val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles + + var editRoles by remember { mutableStateOf(false) } + if (editRoles) { + ConcordRolesDialog( + assignable = assignableRoles, + current = entry.roleIds, + onConfirm = { selected -> + accountViewModel.setConcordRoles(communityId, entry.pubkey, selected) + editRoles = false + }, + onDismiss = { editRoles = false }, + ) + } var confirmRemove by remember { mutableStateOf(false) } if (confirmRemove) { @@ -212,7 +274,7 @@ private fun ConcordMemberRow( ) } - androidx.compose.foundation.layout.Row( + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -241,6 +303,27 @@ private fun ConcordMemberRow( }, ) } + if (viewerCanManageRoles) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.concord_members_roles)) + rolesBlockedReason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + enabled = rolesBlockedReason == null, + onClick = { + editRoles = true + expanded = false + }, + ) + } if (canBan) { DropdownMenuItem( text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) }, @@ -298,6 +381,64 @@ private fun MemberBadge( } } +/** + * Multi-select over the roles the viewer may assign (CORD-04 role grant). + * + * A grant REPLACES the member's role set rather than merging into it, so the box starts + * checked on everything they already hold — otherwise saving would silently strip the + * roles that weren't re-checked. Every currently-held role is guaranteed to appear in + * [assignable]: the caller only opens this when it strictly outranks the member, and the + * member's rank is the *lowest* position they hold, so all of their roles sit strictly + * below us too. Like "Make admin", saving applies immediately — no extra confirmation. + */ +@Composable +private fun ConcordRolesDialog( + assignable: List, + current: Set, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit, +) { + val selected = remember(current) { mutableStateListOf().apply { addAll(current) } } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.concord_members_roles_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringRes(R.string.concord_members_roles_message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + assignable.forEach { role -> + val checked = role.id in selected + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + if (checked) selected.remove(role.id) else selected.add(role.id) + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox(checked = checked, onCheckedChange = null) + Text(role.name.ifBlank { role.id.take(8) }, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selected.toList()) }) { + Text(stringRes(R.string.concord_members_roles_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + /** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */ @Composable private fun ConcordRemoveMemberDialog( @@ -324,6 +465,15 @@ private class RosterEntry( val membership: ConcordMembership, /** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */ val roleName: String?, + /** Every role id the member currently holds — the preselection for the role picker. */ + val roleIds: Set, +) + +/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */ +private class AssignableRole( + val id: String, + val name: String, + val position: Long, ) /** Owner first, then admins, then plain members, then banned last. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt index 171d478aff..a4d966671b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt @@ -22,45 +22,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent - -/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */ -private val RELAY_GROUP_METADATA_KINDS = - listOf( - GroupMetadataEvent.KIND, - GroupAdminsEvent.KIND, - GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, - ) /** * The relay-signed metadata for a NIP-29 group (name/picture/about + admin, * member and role lists), addressed by the group id (`d` tag) and pinned to the * group's host relay. The relay signs these with its own key, so a single-relay * query scoped by `#d` returns exactly this group's directory. + * + * The 39000-39003 metadata block and the 39005 pin list go out as **two separate filters**: relay29-family + * relays (0xchat's included) reject a filter that mixes them and drop the whole REQ, which would leave the + * group with no name, no roster and no membership. See + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS]. */ fun filterRelayGroupState( channel: RelayGroupChannel, since: SincePerRelayMap?, ): List { val relays = channel.relays().toSet() + val scope = mapOf("d" to listOf(channel.groupId.id)) val directory = - relays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to listOf(channel.groupId.id)), - since = since?.get(it)?.time, - ), + relays.flatMap { + val floor = since?.get(it)?.time + listOf( + RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)), + RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 995ff59902..3e93f8fe8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -53,6 +53,7 @@ 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.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -101,13 +102,13 @@ fun RelayGroupChannelListScreen( // updates as directory events arrive with no polling. The initial value is sorted too // so the first frame doesn't reshuffle when the first emission arrives. val allChannels by produceState( - initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }, + initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() }, relay, ) { LocalCache .observeEvents(Filter(kinds = listOf(GroupMetadataEvent.KIND))) .collect { - value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() } + value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index c0834cce19..7a89de9ef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -65,6 +65,7 @@ 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.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -467,8 +468,8 @@ private fun pickCandidates( .asSequence() .filter { it.groupId.id !in forbidden } .filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) } - .sortedBy { it.toBestDisplayName().lowercase() } .toList() + .sortedBySnapshot { it.toBestDisplayName().lowercase() } /** * The set of group ids reachable as descendants of [rootId] on [relay], following each group's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index dfcfbee1dd..a68436ff0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -45,16 +45,43 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent * See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan. */ -/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */ -val RELAY_GROUP_STATE_KINDS = +/** + * The relay's **directory** kinds for a group — metadata + admins + members + roles (39000-39003). + * These four are what NIP-29 relays treat as a group's "metadata" block, and they must be requested + * **alone**: see [RELAY_GROUP_PIN_KINDS]. + */ +val RELAY_GROUP_METADATA_KINDS = listOf( GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, ) +/** + * The pin list (39005), deliberately kept in its **own** filter rather than merged into + * [RELAY_GROUP_METADATA_KINDS]. + * + * NIP-29 relays derived from `relay29`/`khatru29` (0xchat's `groups.0xchat.com` among them) reject a REQ + * whose filter mixes the 39000-39003 metadata kinds with any other kind, replying + * `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`. A single filter asking for + * 39000-39003 **plus** 39005 is therefore dropped **whole** — the group never resolves its name, roster, + * roles or the user's own membership, so it renders as a raw id and offers "Join" to somebody the relay + * already lists as an admin. + * + * Splitting into two filter objects fixes it: those relays evaluate the rule per filter, so the + * metadata filter is served normally and the pins filter is served (or harmlessly ignored) on its own. + */ +val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND) + +/** + * Every relay-signed group *state* kind: metadata + admins + members + roles + pins. Small replaceable + * events. **Never put this list on the wire as one filter** — request [RELAY_GROUP_METADATA_KINDS] and + * [RELAY_GROUP_PIN_KINDS] as separate filters instead (see [RELAY_GROUP_PIN_KINDS]). Kept as the + * semantic "all state kinds" set for cache/consume-side code. + */ +val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS + /** Timeline kinds shown in a group's chat — chat messages and polls. */ val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) @@ -69,13 +96,7 @@ val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, Threa * Narrower than [RELAY_GROUP_STATE_KINDS] on purpose: the directory lists groups, it doesn't need each * group's pin list. */ -val RELAY_GROUP_DIRECTORY_KINDS = - listOf( - GroupMetadataEvent.KIND, - GroupAdminsEvent.KIND, - GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, - ) +val RELAY_GROUP_DIRECTORY_KINDS = RELAY_GROUP_METADATA_KINDS /** How many directory entries to pull per relay when browsing its whole group list. */ const val RELAY_GROUP_DIRECTORY_LIMIT = 500 @@ -93,22 +114,21 @@ private fun byHostRelay(joined: Collection): Map, sinceForRelay: (NormalizedRelayUrl) -> Long?, ): List = - byHostRelay(joined).map { (relay, ids) -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_STATE_KINDS, - tags = mapOf(D_TAG to ids.distinct()), - since = sinceForRelay(relay), - ), + byHostRelay(joined).flatMap { (relay, ids) -> + val scope = mapOf(D_TAG to ids.distinct()) + val since = sinceForRelay(relay) + listOf( + RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 4c797b7d0d..a82cfdef45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -58,6 +59,8 @@ import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview +import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -762,26 +765,7 @@ private fun UserRoomCompose( TimeAgo(lastMessage.createdAt()) }, secondRow = { - LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content -> - if (content != null) { - Text( - content, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - modifier = Modifier.weight(1f), - ) - } else { - Text( - stringRes(R.string.referenced_event_not_found), - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - } + LastMessagePreview(lastMessage, accountViewModel) // A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention. val newestEvent = lastMessage.event @@ -816,6 +800,51 @@ private fun UserRoomCompose( } } +/** + * The one-line preview of the room's newest message. + * + * NIP-04 rooms carry ciphertext in `event.content`, so the preview may only ever come from the + * decryption cache. [chatPreviewOf] keeps the three not-a-body outcomes apart — still decrypting, + * never decryptable, and no event at all — so a message that simply hasn't been opened yet isn't + * mislabelled as unreadable. The pending state resolves on its own: [LoadDecryptedContentOrNull] + * pushes the plaintext into its state as soon as the signer answers. + */ +@Composable +private fun RowScope.LastMessagePreview( + lastMessage: Note, + accountViewModel: AccountViewModel, +) { + LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content -> + // Keyed so a scrolling list doesn't re-scan the DM's `p` tags on every recomposition. + val preview = + remember(lastMessage.event, content) { + chatPreviewOf( + event = lastMessage.event, + decrypted = content, + myPubKey = accountViewModel.account.signer.pubKey, + canDecrypt = accountViewModel.account.isWriteable(), + ) + } + + val text = + when (preview) { + is ChatPreview.Body -> preview.text + ChatPreview.Decrypting -> stringRes(R.string.chat_preview_decrypting) + ChatPreview.Undecryptable -> stringRes(R.string.could_not_decrypt_the_message) + ChatPreview.Missing -> stringRes(R.string.referenced_event_not_found) + } + + Text( + text, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + modifier = Modifier.weight(1f), + ) + } +} + @Composable fun LoadUser( baseUserHex: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabAccountWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabAccountWatcher.kt new file mode 100644 index 0000000000..577a6559b3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabAccountWatcher.kt @@ -0,0 +1,61 @@ +/* + * 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.ui.screen.loggedIn.embed + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles + +/** + * Makes every embedded tab (browser / nsite / napplet) follow an account switch. + * + * Each account gets its own WebView storage jar, and that partition is chosen once, when the WebView is + * constructed — so a warm session built for account A can never serve account B. This asks + * [EmbeddedTabHost] to tear down and re-warm every session whenever the active account's profile changes; + * see [EmbeddedTabHost.rebuildIfProfileChanged] for why reusing them also leaves the tab blank. + * + * The whole logged-in subtree is recreated per account, so this composable is itself brand new after a + * switch — which is exactly why the "which account are the warm sessions built for" marker lives in + * [EmbeddedTabHost] (process-scoped) and not in a `remember` here. + * + * Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader], before them so the stale sessions are + * dropped ahead of the first preload sweep. Draws nothing. + */ +@RequiresApi(Build.VERSION_CODES.R) +@Composable +fun EmbeddedTabAccountWatcher() { + // Read the same opaque profile name the controllers stamp on their sessions, from the same source, so + // the sessions we rebuild are guaranteed to be built against the account we just checked for. + val profile = NappletWebViewProfiles.current() + + // SideEffect, not LaunchedEffect: this must land in the SAME frame as the switch. A LaunchedEffect + // dispatches through the composition's coroutine scope, and an account switch floods the main thread — + // measured 3.0 s and 4.3 s of delay on a real device. For that whole window the tab layer had already + // rebuilt every SandboxedSdkView against the surviving (now-dead) controllers, so every embedded tab + // was a black rectangle. SideEffect runs at the end of the apply phase, so the stale sessions are torn + // down and the epoch bumped before the next composition renders any surface. + // + // Safe to run on every recomposition: [EmbeddedTabHost.rebuildIfProfileChanged] is idempotent — it + // compares against the profile the warm sessions were built for and no-ops when nothing changed. + SideEffect { EmbeddedTabHost.rebuildIfProfileChanged(profile) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index bc0ed87804..7246b19577 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -57,12 +57,14 @@ object EmbeddedTabHost { private set /** - * Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed - * WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change - * means rebuilding the surface — the favorite screens and the preloader key their acquisition on this - * so they re-acquire a freshly-themed session instead of the stale warm one. + * Bumped whenever every warm session must be rebuilt from scratch (see [rebuildAll]) — a DARK/LIGHT + * theme flip, or an account switch. Both the theme (`nightThemedContext`) and the per-account storage + * profile ([com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles]) are locked in at WebView + * construction and can't be changed on a live WebView, so following either means building a new one. + * The favorite screens and the preloader key their acquisition on this, so they re-acquire a freshly + * built session instead of the stale warm one. */ - var themeEpoch by mutableStateOf(0) + var rebuildEpoch by mutableStateOf(0) private set /** Window-space bounds of the active tab's reserved content area. */ @@ -169,15 +171,48 @@ object EmbeddedTabHost { } /** - * The app theme changed: tear down every warm session (their WebViews are pinned to the old theme) - * and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions. - * Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen - * re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface. + * Something a WebView can only pick up at construction changed (the theme, or the account): tear down + * every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly + * built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant + * its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface. */ - fun rebuildAllForTheme() { + fun rebuildAll() { val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } - themeEpoch += 1 + rebuildEpoch += 1 + } + + /** + * Account the warm sessions were built for, as the opaque WebView storage-profile name (null while + * logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`: + * the whole logged-in subtree is rebuilt per account (`key(pubKey)` in `SetAccountCentricViewModelStore`), + * so a remembered "last applied" value would be re-seeded to the NEW account on the very first + * composition after a switch and the change would never be detected. + */ + private var builtForProfile: String? = null + private var profileSeeded = false + + /** + * Rebuilds every warm session when the active account changes, so all embedded apps follow the switch. + * + * A WebView's storage profile is fixed at construction (`WebViewCompat.setProfile` throws once it has + * loaded content), so a live session can't be re-pointed at the new account's jar — it has to be + * rebuilt. Rebuilding is also what makes the tab work at all after a switch: the account-keyed subtree + * is recreated, which disposes each session's `SandboxedSdkView` and closes the sandbox-side session, + * leaving the warm controller holding an already-consumed (and now dead) adapter. Reused as-is, it + * would hand the fresh view no adapter and the tab would render permanently blank. + * + * Covers tabs that aren't on screen too: every warm session is torn down, and the preloader re-warms + * the pinned ones against the new profile, so none can come back still bound to the old account. + */ + fun rebuildIfProfileChanged(profileName: String?) { + if (profileSeeded && builtForProfile == profileName) return + val isFirstCall = !profileSeeded + profileSeeded = true + builtForProfile = profileName + // Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a + // needless bump would restart the preload sweep that is just getting going. + if (!isFirstCall) rebuildAll() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index fcf118d781..e1974d6e54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -215,13 +215,22 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay // can't live in the screen. A spinner until a real page paints, or an error+retry when the load // failed or stalled, so a slow / blank / failed load isn't a bare black/white void. + // + // Gated on the active *id*, not on a live controller: right after [EmbeddedTabHost.rebuildAll] (an + // account switch or a theme flip) the active tab has no session at all for a frame or more, and a + // SandboxedSdkView with no adapter paints nothing but its background — a black rectangle that reads + // as broken. Treat "no session yet" as "still loading" so the spinner covers that window too. val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller - if (activeController != null && bounds.width > 0f && bounds.height > 0f) { - var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) } + if (activeId != null && bounds.width > 0f && bounds.height > 0f) { + var loadStatus by remember(activeId) { mutableStateOf(activeController?.loadStatus ?: EmbeddedLoadStatus()) } var timedOut by remember(activeId) { mutableStateOf(false) } DisposableEffect(activeId, activeController) { - activeController.onLoadStatusChanged = { loadStatus = it } - onDispose { activeController.onLoadStatusChanged = null } + // No session yet — the tab is mid-rebuild after an account switch, or being warmed for the + // first time. Fall back to the "still loading" state so the spinner covers the surface + // instead of leaving a bare black rectangle where a dead/absent adapter paints nothing. + loadStatus = activeController?.loadStatus ?: EmbeddedLoadStatus() + activeController?.onLoadStatusChanged = { loadStatus = it } + onDispose { activeController?.onLoadStatusChanged = null } } // Safety net: nothing painted and nothing actively loading after a grace period → offer a retry. LaunchedEffect(activeId, loadStatus) { @@ -241,10 +250,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { ).size(bounds.width.toDp(), bounds.height.toDp()), ) { EmbeddedLoadOverlay( - failed = loadStatus.failed || timedOut, + // A tab with no session yet can't have failed — it hasn't started. Keep it on + // the spinner so a re-arming tab never flashes an error the user can't act on. + failed = activeController != null && (loadStatus.failed || timedOut), onRetry = { timedOut = false - activeController.retry() + activeController?.retry() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt new file mode 100644 index 0000000000..8354c1d1ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry +import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield + +// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting +// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total). +private const val PRELOAD_ATTEMPTS = 15 +private const val PRELOAD_RETRY_MS = 3_000L + +/** + * Process-scoped owner of the bottom-bar warm-up sweep, driven by [EmbeddedTabPreloader]. + * + * **Why this isn't just a `LaunchedEffect` in the composable.** After an account switch every warm + * session is torn down ([EmbeddedTabHost.rebuildAll]) and must be rebuilt against the new storage + * profile — until that happens the tabs have nothing to show. A `LaunchedEffect` dispatches its body + * through the composition's coroutine scope, and an account switch floods the main thread: the same + * construct made [EmbeddedTabAccountWatcher] fire 3-4 s late before it became a `SideEffect`. Waiting + * that long to even *start* the re-warm is what left the tab blank for seconds after a switch. + * + * So the **kickoff** is synchronous — [request] is called from a `SideEffect`, in the same apply phase + * that bumped the epoch — while the sweep itself stays a coroutine, because it genuinely has to suspend + * (it awaits the network-registry hydration, then retries pending favorites for ~45 s). + * [CoroutineStart.UNDISPATCHED] on [Dispatchers.Main.immediate] means the body runs *inline* up to its + * first real suspension, so on a re-warm (registries already hydrated, so `awaitReady` returns without + * suspending) the first tab is rebuilt before [request] even returns. + */ +@RequiresApi(Build.VERSION_CODES.R) +object EmbeddedTabPreloadSweeper { + private data class SweepKey( + val favoriteIds: List, + val backgroundColor: Int, + val rebuildEpoch: Int, + ) + + private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private var job: Job? = null + private var lastKey: SweepKey? = null + + /** + * Starts (or restarts) the sweep for [favoriteIds]. Idempotent: repeated calls with the same inputs + * no-op, so it is safe to call from a `SideEffect` that runs on every recomposition. A changed + * [rebuildEpoch] — a theme flip or an account switch — cancels the in-flight sweep and starts a fresh + * one, which is what re-warms the tabs the user never opened so none survives bound to the old + * account's storage profile. + */ + fun request( + context: Context, + favoriteIds: List, + backgroundColor: Int, + rebuildEpoch: Int, + ) { + val key = SweepKey(favoriteIds, backgroundColor, rebuildEpoch) + if (lastKey == key) return + lastKey = key + job?.cancel() + job = null + if (favoriteIds.isEmpty()) return + // The sweep outlives any single composition, so it must not pin an Activity. + val appContext = context.applicationContext + job = + scope.launch(start = CoroutineStart.UNDISPATCHED) { + sweep(appContext, favoriteIds, backgroundColor) + } + } + + private suspend fun CoroutineScope.sweep( + context: Context, + favoriteIds: List, + backgroundColor: Int, + ) { + // Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads + // the bare Tor default and would route a site the user pinned to the open web through Tor (or stall + // it waiting for Tor), which is exactly what breaks Tor-incompatible servers. On a re-warm these + // are already hydrated, so they return without suspending and the first tab is built inline. + WebAppNetworkRegistry.init(context) + NappletNetworkRegistry.init(context) + WebAppNetworkRegistry.awaitReady() + NappletNetworkRegistry.awaitReady() + var attempt = 0 + while (isActive) { + val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id } + var stillPending = false + for (id in favoriteIds) { + val app = byId[id] ?: continue + if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true + // Each preload may build + attach a WebView on this (main) thread; yield between favorites + // so the sweep doesn't monopolize the frame and jank the paint that follows. + yield() + } + if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break + delay(PRELOAD_RETRY_MS) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt index 39ffea72e1..7bed247a60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt @@ -24,7 +24,7 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.toArgb @@ -33,19 +33,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry -import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry -import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.yield - -// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting -// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total). -private const val PRELOAD_ATTEMPTS = 15 -private const val PRELOAD_RETRY_MS = 3_000L /** * Warms every bottom-bar favorite at startup so the first tap is instant — the surfaces are built, @@ -54,7 +43,9 @@ private const val PRELOAD_RETRY_MS = 3_000L * * Mount once next to [EmbeddedTabLayer]. Draws nothing; it just drives acquisition. Napplet favorites * whose events haven't synced yet, and Tor-routed sites whose proxy isn't up yet, are retried for a - * bounded window (the latter so a clearnet preload can never race ahead of Tor). + * bounded window (the latter so a clearnet preload can never race ahead of Tor) — that retry loop, and + * everything else that has to suspend, lives in [EmbeddedTabPreloadSweeper]; this composable only kicks + * it off, synchronously. */ @RequiresApi(Build.VERSION_CODES.R) @Composable @@ -66,40 +57,37 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) { .collectAsStateWithLifecycle() val favoriteIds = bottomBarItems.favoriteIds() - // Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a - // full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it. + // Subscribe to the epoch here (a read inside a SideEffect wouldn't recompose us), so a bump that lands + // outside our apply phase — [EmbeddedTabThemeWatcher] bumps it from a LaunchedEffect — still re-runs the + // kickoff below. The SideEffect re-reads it, so a bump in the SAME apply phase is picked up immediately. + val observedEpoch = EmbeddedTabHost.rebuildEpoch + val density = LocalDensity.current val configuration = LocalConfiguration.current - LaunchedEffect(configuration) { + + // Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a + // full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it. + // Synchronous, and declared BEFORE the kickoff, because the kickoff is synchronous too: as a + // LaunchedEffect this would now land *after* the first preload and hand it the off-screen fallback. + SideEffect { with(density) { EmbeddedTabHost.seedBoundsIfUnset(Rect(0f, 0f, configuration.screenWidthDp.dp.toPx(), configuration.screenHeightDp.dp.toPx())) } } - // Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch, - // so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown). - LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) { - if (favoriteIds.isEmpty()) return@LaunchedEffect - // Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads - // the bare Tor default and would route a site the user pinned to the open web through Tor (or stall - // it waiting for Tor), which is exactly what breaks Tor-incompatible servers. - WebAppNetworkRegistry.init(context) - NappletNetworkRegistry.init(context) - WebAppNetworkRegistry.awaitReady() - NappletNetworkRegistry.awaitReady() - var attempt = 0 - while (isActive) { - val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id } - var stillPending = false - for (id in favoriteIds) { - val app = byId[id] ?: continue - if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true - // Each preload may build + attach a WebView on this (main) thread; yield between favorites - // so the startup sweep doesn't monopolize the frame and jank the first paint. - yield() - } - if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break - delay(PRELOAD_RETRY_MS) - } + // Re-warms after a theme flip or an account switch: [rebuildAll] tears down the warm sessions and bumps + // the epoch, so this sweep re-acquires them freshly built (keying on the epoch also orders it after the + // teardown). This is also what re-warms tabs the user never opened, so none survives bound to the old + // account's storage profile. + // + // SideEffect, not LaunchedEffect: [EmbeddedTabAccountWatcher] tears the sessions down in the apply phase + // of the switch, and until this sweep runs there is nothing for the tab to show. A LaunchedEffect + // dispatches through the composition's scope, and an account switch floods the main thread — the very + // congestion that made the watcher itself fire 3-4 s late. Running here re-arms the tabs in the same + // frame that dropped them. The epoch is re-read inside the lambda so we see the watcher's bump (its + // SideEffect is ordered before ours), and [EmbeddedTabPreloadSweeper.request] is idempotent, so running + // on every recomposition is free. + SideEffect { + EmbeddedTabPreloadSweeper.request(context, favoriteIds, backgroundColor, maxOf(observedEpoch, EmbeddedTabHost.rebuildEpoch)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt index 2a22372db8..9c0ddcf11a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt @@ -61,7 +61,7 @@ fun EmbeddedTabThemeWatcher() { LaunchedEffect(resolvedDark) { if (applied.value != resolvedDark) { applied.value = resolvedDark - EmbeddedTabHost.rebuildAllForTheme() + EmbeddedTabHost.rebuildAll() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index c8e8e6f7c2..8eb3ea1e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -36,6 +36,7 @@ import androidx.annotation.RequiresApi import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge @@ -72,9 +73,18 @@ class EmbeddedNostrAppController( private var sandboxedSdkView: SandboxedSdkView? = null private var pendingAdapter: SandboxedUiAdapter? = null + /** + * True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can + * only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the + * sandbox destroys its WebView, so the adapter is dead. See [attachView]. + */ + private var adapterDelivered = false + // A single NappletHostService instance serves every embedded napplet tab, so each controller stamps // its own id on every message; the provider uses it to route controls/state/IME to this tab. - private val sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}" + // Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the + // previous view can never reap the replacement. + private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}" // A parked tab can be hidden (paused) before the service even binds, so the pause message is // dropped (no messenger yet). Remember the intent and replay it right after the session is created, @@ -129,6 +139,7 @@ class EmbeddedNostrAppController( serviceMessenger = null sandboxedSdkView = null pendingAdapter = null + adapterDelivered = false onStateChanged = null onNotice = null onImeEvent = null @@ -136,14 +147,44 @@ class EmbeddedNostrAppController( onLoadStatusChanged = null } + /** + * Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the + * remote session when this controller is being re-used by a *second* view. + * + * A warm controller outlives the composition (it lives in the process-scoped + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost]), but its [SandboxedSdkView] + * does not: an account switch rebuilds the whole logged-in subtree, disposing every surface. That + * disposal makes privacysandbox close the remote session and the sandbox destroy its WebView, so the + * adapter already handed out is dead and cannot serve the fresh view. A [SandboxedSdkView] with no + * adapter never builds a ContentView/SurfaceView and paints nothing but its background colour, forever. + * + * So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new + * session; the reply arms this view. [sendCreateSession] re-stamps the CURRENT account's storage + * profile, so re-arming can never resurrect the previous account's jar. + */ override fun attachView(view: SandboxedSdkView) { sandboxedSdkView = view // Paint the surface placeholder in the app's theme background so there's no white flash before // the remote WebView delivers its first frame. view.setBackgroundColor(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE)) - pendingAdapter?.let { - view.setAdapter(it) - pendingAdapter = null + val adapter = pendingAdapter + when { + adapter != null -> { + pendingAdapter = null + adapterDelivered = true + view.setAdapter(adapter) + } + // No adapter in hand and one was already spent on a previous (now disposed) view: the session + // behind it is gone, so this view would stay blank forever. Re-create it. + adapterDelivered -> { + // Mint a FRESH session id: the disposed view's Session.close() reaches the sandbox + // asynchronously and can land AFTER this create. Reusing the id would let that late close + // reap the session we just asked for, leaving the surface black. + sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}" + adapterDelivered = false + sendCreateSession() + } + // else: the first session is still in flight; MSG_SESSION_READY will arm this view. } } @@ -157,7 +198,15 @@ class EmbeddedNostrAppController( val msg = Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply { replyTo = incoming - data = Bundle(params).apply { putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) } + data = + Bundle(params).apply { + putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) + // Re-stamp the storage partition at SEND time rather than trusting the one baked + // into [params] at construction: a session re-created for a new view (see + // [attachView]) must land in the CURRENT account's jar, never the one this + // controller was originally built for. + putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) + } } runCatching { serviceMessenger?.send(msg) } // Replay a pause that was requested before we had a messenger to send it on (parked-before-bound), @@ -172,7 +221,12 @@ class EmbeddedNostrAppController( val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo) val view = sandboxedSdkView - if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter + if (view != null) { + adapterDelivered = true + view.setAdapter(adapter) + } else { + pendingAdapter = adapter + } } NappletEmbedContract.MSG_STATE -> { val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index ff44b1b874..05b27d3a1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -114,7 +114,7 @@ private fun EmbeddedNostrAppTab( // Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted // on a theme flip (the params carry the resolved theme into the sandbox host's WebView). - val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } + val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } if (params == null) { UnavailableTab(coordinate, accountViewModel, nav) return @@ -134,7 +134,7 @@ private fun EmbeddedNostrAppTab( val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = - remember(id, EmbeddedTabHost.themeEpoch) { + remember(id, EmbeddedTabHost.rebuildEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 920d0dd38a..fcc64c3386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -82,6 +82,8 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel +import com.vitorpamplona.amethyst.napplet.NappletBrokerService +import com.vitorpamplona.amethyst.napplet.counterpartyLabel import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes import com.vitorpamplona.amethyst.napplet.resolveNappletMeta @@ -117,7 +119,7 @@ fun ConnectedAppDetailScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val capabilityLedger = Amethyst.instance.nappletPermissionLedger val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } val untitled = stringResource(CommonsR.string.napplet_untitled) @@ -219,7 +221,15 @@ fun ConnectedAppDetailScreen( PolicyPicker( selected = current.signerPolicy, onSelect = { newPolicy -> - mutate { signerLedger.setPolicy(coordinate, newPolicy) } + mutate { + signerLedger.setPolicy(coordinate, newPolicy) + // Live session grants are consulted BEFORE the policy, so tightening an app + // to PARANOID would not have stopped it signing — the grant it already holds + // short-circuits the check the new policy would fail. Changing the trust + // level is a decision about how this app is treated from now on, so drop + // what it is holding and let the new policy actually apply. + NappletBrokerService.revokeSessionGrants(coordinate) + } }, ) } @@ -238,7 +248,14 @@ fun ConnectedAppDetailScreen( OpOverrideRow( opKey = opKey, decision = decision, - onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } }, + onRevoke = { + mutate { + signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) + // The persisted override is gone, but a live "allow for this session" + // grant would keep authorizing this app until the broker dies. + NappletBrokerService.revokeSessionGrants(coordinate) + } + }, ) } } @@ -294,6 +311,11 @@ fun ConnectedAppDetailScreen( signerLedger.revokeAll(coordinate) } capabilityLedger.revokeAll(identity) + // Forgetting an app has to stop it signing *now*. The two ledgers above only + // drop persisted + capability grants; the broker separately holds the signer's + // in-memory "allow for this session" grants, which would otherwise keep the + // app authorized for as long as any applet surface stays open. + NappletBrokerService.revokeSessionGrants(coordinate) } nav.popBack() }, @@ -703,6 +725,7 @@ private fun NostrSignerOp.opLabel(): String = is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind) NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt) NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt) + is NostrSignerOp.DecryptFrom -> stringResource(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty)) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 2768f983b3..e6ab7d4a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -96,7 +96,7 @@ fun ConnectedAppsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val capabilityLedger = Amethyst.instance.nappletPermissionLedger val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } var items by remember { mutableStateOf?>(null) } diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index e1a3511d14..2e13c653c3 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -36,6 +36,7 @@ صورة القناة لم يتم العثور على الحدث المشار إليه لا يمكن فك تشفير الرسالة + صورة المجموعة محتوى فاضح إشعار relay @@ -730,6 +731,7 @@ يريد هذا nApplet قراءة الأحداث من relay الخاصة بك. يريد هذا nApplet استخدام مساحة التخزين الخاصة به. يريد هذا nApplet دفع فاتورة Lightning. + يريد هذا nApplet جلب مورد ويب. يريد هذا nApplet تحميل ملف إلى خادم الوسائط الخاص بك. يريد هذا nApplet عرض إشعارات لك. @@ -742,11 +744,24 @@ يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats. يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 00e9dc9d00..f5016544b3 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -25,6 +25,7 @@ চ্যানেল ইমেজ উল্লেখিত ইভেন্টটি পাওয়া যায় নি মেসেজটি ডিক্রিপ্ট করা যায় নি + দলগত ছবি খোলামেলা আধেয় রিলে নোটিশ @@ -703,6 +704,7 @@ এই nApplet আপনার relay-গুলো থেকে ইভেন্ট পড়তে চায়। এই nApplet তার প্রাইভেট স্টোরেজ ব্যবহার করতে চায়। এই nApplet একটি Lightning ইনভয়েস পরিশোধ করতে চায়। + এই nApplet একটি ওয়েব রিসোর্স আনতে চায়। এই nApplet আপনার মিডিয়া সার্ভারে একটি ফাইল আপলোড করতে চায়। এই nApplet আপনাকে বিজ্ঞপ্তি দেখাতে চায়। @@ -711,11 +713,24 @@ এই nApplet %1$d sat-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়। এই nApplet %1$d sats-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়। + + + + + + + + + diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 7e333c6763..f3e179a1f1 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -31,6 +31,7 @@ Obrázek kanálu Odkazovaná událost nebyla nalezena Nepodařilo se dešifrovat zprávu + Obrázek skupiny Explicitní obsah Oznámení relé @@ -832,6 +833,7 @@ Tento nApplet chce číst eventy z vašich relays. Tento nApplet chce používat své soukromé úložiště. Tento nApplet chce zaplatit Lightning fakturu. + Tento nApplet chce načíst webový zdroj. Tento nApplet chce nahrát soubor na váš mediální server. Tento nApplet vám chce zobrazovat oznámení. @@ -842,6 +844,15 @@ Tento nApplet chce zaplatit Lightning fakturu za %1$d sats. Tento nApplet chce zaplatit Lightning fakturu za %1$d sats. + + + + + Připojit k Nostr se chce připojit k vašemu účtu Nostr @@ -877,6 +888,10 @@ zašifrovat zprávu číst vaše soukromé zprávy + + + + Připojené aplikace Odvolat všechna oprávnění diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 2b2913c386..55d035ee8f 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -25,6 +25,7 @@ Kanalbild Referenziertes Ereignis nicht gefunden Nachricht konnte nicht entschlüsselt werden + Gruppenbild Anstößiger Inhalt Relay-Hinweis @@ -810,6 +811,7 @@ Dieses nApplet möchte Ereignisse von Ihren Relays lesen. Dieses nApplet möchte seinen privaten Speicher verwenden. Dieses nApplet möchte eine Lightning-Rechnung bezahlen. + Dieses nApplet möchte eine Web-Ressource abrufen. Dieses nApplet möchte eine Datei auf Ihren Medienserver hochladen. Dieses nApplet möchte Ihnen Benachrichtigungen anzeigen. @@ -818,6 +820,15 @@ Dieses nApplet möchte eine Lightning-Rechnung über %1$d sat bezahlen. Dieses nApplet möchte eine Lightning-Rechnung über %1$d sats bezahlen. + + + + + Mit Nostr verbinden möchte sich mit deinem Nostr-Konto verbinden @@ -853,6 +864,10 @@ eine Nachricht verschlüsseln deine privaten Nachrichten lesen + + + + Verbundene Apps Alle Berechtigungen widerrufen diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index a5ef22cc92..165b8d395b 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -24,6 +24,7 @@ Εικόνα Καναλιού Η δημοσίευση δεν βρέθηκε Αδυναμία αποκρυπτογράφησης μηνύματος + Εικόνα Ομάδας Ακριβές Περιεχόμενο Ειδοποίηση relay @@ -691,6 +692,7 @@ Αυτό το nApplet θέλει να διαβάσει συμβάντα από τα relay σας. Αυτό το nApplet θέλει να χρησιμοποιήσει την ιδιωτική του αποθήκευση. Αυτό το nApplet θέλει να πληρώσει ένα Lightning τιμολόγιο. + Αυτό το nApplet θέλει να λάβει έναν πόρο από το διαδίκτυο. Αυτό το nApplet θέλει να μεταφορτώσει ένα αρχείο στον διακομιστή πολυμέσων σας. Αυτό το nApplet θέλει να σας εμφανίσει ειδοποιήσεις. @@ -699,11 +701,24 @@ Αυτό το nApplet θέλει να πληρώσει ένα Lightning τιμολόγιο αξίας %1$d sat. Αυτό το nApplet θέλει να πληρώσει ένα Lightning τιμολόγιο αξίας %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index b3b41a0643..092f9181d8 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -7,6 +7,7 @@ Scan QR Show Anyway 👀 + @@ -15,11 +16,25 @@ + + + + + + + + + + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index def35ae0b4..b096aa2554 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -24,6 +24,7 @@ Kanala Bildo Referencita evento netrovita Mesaĝo nemalĉifrebla + Grupa Bildo Eksplicita Enhavo Relay-avizo @@ -700,6 +701,7 @@ Ĉi tiu nApplet volas legi eventojn el viaj relay-oj. Ĉi tiu nApplet volas uzi sian privatan stokadan lokon. Ĉi tiu nApplet volas pagi Lightning-fakturon. + Ĉi tiu nApplet volas alporti retan rimedon. Ĉi tiu nApplet volas alŝuti dosieron al via amaskomunikilara servilo. Ĉi tiu nApplet volas montri al vi sciigojn. @@ -708,11 +710,24 @@ Ĉi tiu nApplet volas pagi Lightning-fakturon por %1$d sat. Ĉi tiu nApplet volas pagi Lightning-fakturon por %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index b697900534..fe28a9b7f7 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -25,6 +25,7 @@ Imagen del canal Evento referenciado no encontrado No se pudo desencriptar el mensaje + Imagen de grupo Contenido explícito aviso_relé @@ -716,6 +717,7 @@ Este nApplet quiere leer eventos de tus relés. Este nApplet quiere utilizar su almacenamiento privado. Este nApplet quiere pagar una factura Lightning. + Este nApplet quiere obtener un recurso web. Este nApplet quiere subir un archivo a tu servidor de medios. Este nApplet quiere mostrarte notificaciones. @@ -724,11 +726,24 @@ Este nApplet quiere pagar una factura Lightning de %1$d sat. Este nApplet quiere pagar una factura Lightning de %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 1423a619ae..29dc509576 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -25,6 +25,7 @@ Imagen del canal No se encontró el evento referenciado No se pudo desencriptar el mensaje + Foto de grupo Contenido explícito aviso_relé @@ -709,6 +710,7 @@ Este nApplet quiere leer eventos de tus relés. Este nApplet quiere utilizar su almacenamiento privado. Este nApplet quiere pagar una factura Lightning. + Este nApplet quiere obtener un recurso web. Este nApplet quiere subir un archivo a tu servidor de medios. Este nApplet quiere mostrarte notificaciones. @@ -717,11 +719,24 @@ Este nApplet quiere pagar una factura Lightning de %1$d sat. Este nApplet quiere pagar una factura Lightning de %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index cf1463c32a..78847d4cfd 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -25,6 +25,7 @@ Imagen del canal No se encontró el evento referenciado No se pudo desencriptar el mensaje + Imagen del grupo Contenido explícito aviso_relé @@ -709,6 +710,7 @@ Este nApplet quiere leer eventos de tus relés. Este nApplet quiere utilizar su almacenamiento privado. Este nApplet quiere pagar una factura Lightning. + Este nApplet quiere obtener un recurso web. Este nApplet quiere subir un archivo a tu servidor de medios. Este nApplet quiere mostrarte notificaciones. @@ -717,11 +719,24 @@ Este nApplet quiere pagar una factura Lightning de %1$d sat. Este nApplet quiere pagar una factura Lightning de %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 7a11363ea0..a94fe4677f 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -25,6 +25,7 @@ تصویر کانال رویداد مورد نظر یافت نشد پیام رمزگشایی نشد + تصویر گروه محتوای نامناسب اطلاعیه relay @@ -702,6 +703,7 @@ این nApplet می‌خواهد رویدادها را از relay‌های شما بخواند. این nApplet می‌خواهد از ذخیره‌سازی خصوصی خود استفاده کند. این nApplet می‌خواهد یک فاکتور Lightning پرداخت کند. + این nApplet می‌خواهد یک منبع وب دریافت کند. این nApplet می‌خواهد یک فایل به سرور رسانه‌ای شما بارگذاری کند. این nApplet می‌خواهد به شما اعلان نشان دهد. @@ -710,11 +712,24 @@ این nApplet می‌خواهد یک فاکتور Lightning به مبلغ %1$d sat پرداخت کند. این nApplet می‌خواهد یک فاکتور Lightning به مبلغ %1$d sat پرداخت کند. + + + + + + + + + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 8613ffc245..ecf025e7d1 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -24,6 +24,7 @@ Kanavan kuva Viitattua tapahtumaa ei löytynyt Viestin purku epäonnistui + Ryhmäkuva Julkeaa sisältöä relay-ilmoitus @@ -693,6 +694,7 @@ Tämä nApplet haluaa lukea tapahtumia relayistasi. Tämä nApplet haluaa käyttää yksityistä tallennustaan. Tämä nApplet haluaa maksaa Lightning-laskun. + Tämä nApplet haluaa hakea verkkoresurssin. Tämä nApplet haluaa ladata tiedoston mediapalvelimellesi. Tämä nApplet haluaa näyttää sinulle ilmoituksia. @@ -701,11 +703,24 @@ Tämä nApplet haluaa maksaa Lightning-laskun, jonka arvo on %1$d sat. Tämä nApplet haluaa maksaa Lightning-laskun, jonka arvo on %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 5c1cff2d2c..2ee21db080 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -24,6 +24,7 @@ Image du canal référence de l\'évènement non trouvée Impossible de déchiffrer le message + Image de groupe Contenu choquant Annonce du serveur @@ -671,6 +672,7 @@ Ce nApplet veut lire des événements depuis vos relais. Ce nApplet veut utiliser son stockage privé. Ce nApplet veut payer une facture Lightning. + Ce nApplet veut récupérer une ressource web. Ce nApplet veut téléverser un fichier sur votre serveur multimédia. Ce nApplet veut vous afficher des notifications. @@ -679,11 +681,24 @@ Ce nApplet veut payer une facture Lightning de %1$d sat. Ce nApplet veut payer une facture Lightning de %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 7e4065c7b3..c24234f08c 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -25,6 +25,7 @@ Image du canal référence de l\'évènement non trouvée Impossible de déchiffrer le message + Image de groupe Contenu choquant Annonce du serveur @@ -721,6 +722,7 @@ Ce nApplet veut lire des événements depuis vos relais. Ce nApplet veut utiliser son stockage privé. Ce nApplet veut payer une facture Lightning. + Ce nApplet veut récupérer une ressource web. Ce nApplet veut téléverser un fichier sur votre serveur multimédia. Ce nApplet veut vous afficher des notifications. @@ -729,6 +731,15 @@ Ce nApplet veut payer une facture Lightning de %1$d sat. Ce nApplet veut payer une facture Lightning de %1$d sats. + + + + + Se connecter à Nostr @@ -760,6 +771,10 @@ chiffrer un message lire vos messages privés + + + + Applications connectées Révoquer toutes les autorisations diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 41ca835092..e3800d3a2f 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -25,6 +25,7 @@ प्रणाली चित्र उद्धृत घटना अप्राप्त सन्देश का अरहस्यीकरण असफल + समूह चित्र अभद्र विषयवस्तु पुनःप्रसारक सूचना @@ -810,6 +811,7 @@ यह नोस्टर संलग्नक्रमक आपके पुनःप्रसारकों से घटनाओं को पढना चाहता है। यह नोस्टर संलग्नक्रमक अपना निजी भण्डार का प्रयेग करना चाहता है। यह नोस्टर संलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है। + यह नोस्टर संलग्नक्रमक एक जाल संसाधन लाना चाहता है। यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके प्रसारसंगणक तक आरोहण करना चाहता है। यह नोस्टर संलग्नक्रमक आपको सूचनाएँ दिखाना चाहता है। @@ -818,6 +820,15 @@ यह नोस्टर संलग्नक्रमक %1$d साट् का एक लैटनिंग चालान का भुगतान करना चाहता है। यह नोस्टर संलग्नक्रमक %1$d साट्स का एक लैटनिंग चालान का भुगतान करना चाहता है। + + + + + नोस्टर से संयोजन आपके नोस्टर लेखा के साथ संयोजन करना चाहता है @@ -853,6 +864,10 @@ सन्देश रहस्यीकरण आपके निजी सन्देशों का पठन + + + + संयोजित क्रमक सभी अनुमतियों का निराकरण diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 5ab2d4f74e..4a82d6278c 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -25,6 +25,7 @@ Csatorna profilképe A hivatkozott esemény nem található Nem sikerült visszafejteni az üzenetet + Csoport profilképe Szókimondó tartalom Átjátszóval kapcsolatos megjegyzések @@ -811,6 +812,7 @@ Ez a nKisalkalmazás eseményeket szeretne olvasni az Ön átjátszóiról. Ez a nKisalkalmazás az Ön privát tárhelyét szeretné használni. Ez a nKisalkalmazás ki szeretne fizetni egy Lightning számlát. + Ez a nKisalkalmazás le szeretne kérni egy webes erőforrást. Ez a nKisalkalmazás fel szeretne tölteni egy fájlt az Ön médiakiszolgálójára. Ez a nKisalkalmazás értesítéseket szeretne megjeleníteni Önnek. @@ -819,6 +821,15 @@ Ez a nKisalkalmazás ki szeretne fizetni egy %1$d satoshi értékű Lightning számlát. Ez a nKisalkalmazás ki szeretne fizetni egy %1$d satoshi értékű Lightning számlát. + + + + + Kapcsolódás a NOSTR-hoz szeretne csatlakozni a saját Nostr-fiókjához @@ -854,6 +865,10 @@ egy üzenet titkosítása saját privát üzenetek olvasása + + + + Összekapcsolt alkalmazások Minden engedély visszavonása diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 292f21e971..b891e0f259 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -21,6 +21,7 @@ Gambar Kanal Referensi event tidak dapat ditemukan Tidak dapat mendekripsi pesan + Gambar Grup Konten Eksplisit Pemberitahuan relay @@ -679,6 +680,7 @@ nApplet ini ingin membaca event dari relay Anda. nApplet ini ingin menggunakan penyimpanan pribadinya. nApplet ini ingin membayar invoice Lightning. + nApplet ini ingin mengambil sumber daya web. nApplet ini ingin mengunggah file ke server media Anda. nApplet ini ingin menampilkan notifikasi kepada Anda. @@ -686,11 +688,24 @@ nApplet ini ingin membayar invoice Lightning sebesar %1$d sat. + + + + + + + + + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 3f8a9eabd3..16a0556992 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -24,6 +24,7 @@ Foto del canale L\'evento di riferimento non è stato trovato Impossibile decriptare il messaggio + Foto del gruppo Contenuto esplicito Avviso relay @@ -683,6 +684,7 @@ Questa nApplet vuole leggere eventi dai tuoi relay. Questa nApplet vuole utilizzare la propria archiviazione privata. Questa nApplet vuole pagare una fattura Lightning. + Questa nApplet vuole recuperare una risorsa web. Questa nApplet vuole caricare un file sul tuo server multimediale. Questa nApplet vuole mostrarti delle notifiche. @@ -691,11 +693,24 @@ Questa nApplet vuole pagare una fattura Lightning di %1$d sat. Questa nApplet vuole pagare una fattura Lightning di %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 8eb83bb1a9..02e0d059a1 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -21,6 +21,7 @@ チャンネル画像 参照先のイベントが見つかりません メッセージが復号できませんでした + グループ画像 露骨なコンテンツ relay 通知 @@ -691,6 +692,7 @@ この nApplet はあなたの relay からイベントを読み取ろうとしています。 この nApplet はプライベートストレージを使用しようとしています。 この nApplet は Lightning インボイスを支払おうとしています。 + この nApplet はウェブリソースを取得しようとしています。 この nApplet はメディアサーバーにファイルをアップロードしようとしています。 この nApplet はあなたに通知を表示しようとしています。 @@ -698,11 +700,24 @@ この nApplet は %1$d sats の Lightning インボイスを支払おうとしています。 + + + + + + + + + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index dad1381f3b..cadf07877c 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -21,6 +21,7 @@ 채널 이미지 참조된 이벤트를 찾을 수 없습니다 메시지를 복호화할 수 없습니다 + 그룹 사진 성인 콘텐츠 relay 공지 @@ -688,6 +689,7 @@ 이 nApplet이 relay에서 이벤트를 읽으려고 합니다. 이 nApplet이 개인 저장소를 사용하려고 합니다. 이 nApplet이 Lightning 인보이스를 결제하려고 합니다. + 이 nApplet이 웹 리소스를 가져오려고 합니다. 이 nApplet이 미디어 서버에 파일을 업로드하려고 합니다. 이 nApplet이 알림을 표시하려고 합니다. @@ -695,11 +697,24 @@ 이 nApplet이 %1$d sats의 Lightning 인보이스를 결제하려고 합니다. + + + + + + + + + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index af7cf4673e..905403df75 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -28,6 +28,7 @@ Kanāla attēls Atsaucētais notikums nav atrasts Neizdevās atšifrēt ziņojumu + Grupas attēls Nepiedienīgs saturs Relay paziņojums @@ -704,6 +705,7 @@ Šis nApplet vēlas lasīt notikumus no jūsu relays. Šis nApplet vēlas izmantot savu privāto krātuvi. Šis nApplet vēlas apmaksāt Lightning rēķinu. + Šis nApplet vēlas iegūt tīmekļa resursu. Šis nApplet vēlas augšupielādēt failu uz jūsu mediju serveri. Šis nApplet vēlas rādīt jums paziņojumus. @@ -713,11 +715,24 @@ Šī nApplet vēlas apmaksāt Lightning rēķinu par %1$d sat. Šī nApplet vēlas apmaksāt Lightning rēķinu par %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 06b2cab4a3..538ea1ed90 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -25,6 +25,7 @@ Kanaalafbeelding Verwezen bericht niet gevonden Kon bericht niet ontsleutelen + Groepsafbeelding Expliciete inhoud Relay-bericht @@ -711,6 +712,7 @@ Deze nApplet wil events van je relays lezen. Deze nApplet wil gebruikmaken van zijn privéopslag. Deze nApplet wil een Lightning-invoice betalen. + Deze nApplet wil een webbron ophalen. Deze nApplet wil een bestand uploaden naar je mediaserver. Deze nApplet wil je meldingen tonen. @@ -719,6 +721,15 @@ Deze nApplet wil een Lightning-invoice van %1$d sat betalen. Deze nApplet wil een Lightning-invoice van %1$d sats betalen. + + + + + Verbinden met Nostr wil verbinding maken met je Nostr-account @@ -754,6 +765,10 @@ een bericht versleutelen je privéberichten lezen + + + + Verbonden apps Alle machtigingen intrekken diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 0f0b9a0ed5..7d1daa2575 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -31,6 +31,7 @@ Zdjęcie kanału Przywołane zdarzenie nie zostało znalezione Nie można odszyfrować wiadomości + Zdjęcie grupy Niedozwolona zawartość Uwagi transmitera @@ -832,6 +833,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ten nApplet chce odczytywać wydarzenia z twoich transmiterów. Ta aplikacja nApplet chce korzystać ze swojego prywatnego schowka. Ta aplikacja nApplet chce zapłacić fakturę w systemie Lightning. + Ten nApplet chce pobrać zasób internetowy. Ten nApplet chce przesłać plik na Twój serwer multimedialny. Ten nApplet chce pokazywać Ci powiadomienia. @@ -842,6 +844,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszy. Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszów. + + + + + Połącz z Nostr chce połączyć się z Twoim kontem Nostr @@ -877,6 +888,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest zaszyfruj wiadomość odczytaj prywatne wiadomości + + + + Podłączone aplikacje Cofnij wszystkie uprawnienia diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index ee515a946c..bdec7a592f 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -25,6 +25,7 @@ Imagem do Canal Evento referenciado não encontrado Não foi possível descriptografar a mensagem + Imagem do grupo Conteúdo explícito Aviso do relay @@ -808,6 +809,7 @@ Este nApplet quer ler eventos dos seus relays. Este nApplet quer usar seu armazenamento privado. Este nApplet quer pagar uma fatura Lightning. + Este nApplet quer buscar um recurso da web. Este nApplet quer enviar um arquivo para o seu servidor de mídia. Este nApplet quer mostrar notificações para você. @@ -816,6 +818,15 @@ Este nApplet quer pagar uma fatura Lightning de %1$d sat. Este nApplet quer pagar uma fatura Lightning de %1$d sats. + + + + + Conectar ao Nostr quer se conectar à sua conta Nostr @@ -851,6 +862,10 @@ criptografar uma mensagem ler suas mensagens privadas + + + + Apps conectados Revogar todas as permissões diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 3c7d11d0b1..d36024ccd0 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -24,6 +24,7 @@ Imagem do Canal Evento referenciado não encontrado Não foi possível descriptografar a mensagem + Imagem do grupo Conteúdo explícito Aviso do relay @@ -683,6 +684,7 @@ Este nApplet quer ler eventos dos seus relays. Este nApplet quer usar seu armazenamento privado. Este nApplet quer pagar uma fatura Lightning. + Este nApplet quer buscar um recurso da web. Este nApplet quer enviar um arquivo para o seu servidor de mídia. Este nApplet quer mostrar notificações para você. @@ -691,11 +693,24 @@ Este nApplet quer pagar uma fatura Lightning de %1$d sat. Este nApplet quer pagar uma fatura Lightning de %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 36edbd53c7..cd92e288f1 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -31,6 +31,7 @@ Фото канала Связанное событие не найдено Не удалось расшифровать сообщение + Фото группы Запрещённый контент Уведомление relay @@ -713,6 +714,7 @@ Этот nApplet хочет читать события с ваших relay. Этот nApplet хочет использовать своё приватное хранилище. Этот nApplet хочет оплатить Lightning-инвойс. + Этот nApplet хочет загрузить веб-ресурс. Этот nApplet хочет загрузить файл на ваш медиасервер. Этот nApplet хочет показывать вам уведомления. @@ -723,11 +725,24 @@ Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats. Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index ba0aa55738..bf149ae132 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -30,6 +30,7 @@ Изображение канала Указанное событие не найдено Не удалось расшифровать сообщение + Изображение группы Откровенный контент Уведомление relay @@ -701,6 +702,7 @@ Этот nApplet хочет читать события с ваших relay. Этот nApplet хочет использовать своё приватное хранилище. Этот nApplet хочет оплатить Lightning-инвойс. + Этот nApplet хочет загрузить веб-ресурс. Этот nApplet хочет загрузить файл на ваш медиасервер. Этот nApplet хочет показывать вам уведомления. @@ -711,11 +713,24 @@ Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats. Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 7da4eb2369..f9227271c8 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -31,6 +31,7 @@ Slika kanala Referenčni dogodek ni najden Dešifriranje sporočila ni uspelo + Slika skupine Eksplicitna vsebina Obvestilo releja @@ -312,6 +313,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Že imam Nostr račun? Nalagam vir vsebin Račun se nalaga + Še ni kanalov. Pošlji sliko Odpri kanal Dodaj pasico @@ -343,9 +345,29 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Shrani Člani Povišaj v administratorja + Blokiraj + Odstrani blokado + Odstrani iz skupnosti + Odstranim člana? + Odstrani + Lastnik + Administrator + Blokiran + Pridruži se skupnosti + Povabilo Concord skupnosti + Concord + Po skupnosti + v pogovoru + Niz objav + + %1$d odgovor + %1$d odgovora + %1$d odgovori + %1$d odgovorov + šifrirano starejša različica Iščem originalno sporočilo… @@ -783,6 +805,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta nApplet želi prebrati dogodke z vaših relejev. Ta nApplet želi uporabiti svojo zasebno shrambo. Ta nApplet želi plačati Lightning račun. + Ta nApplet želi pridobiti spletni vir. Ta nApplet želi naložiti datoteko na vaš medijski strežnik. Ta nApplet vam želi prikazati obvestila. @@ -793,6 +816,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta nApplet želi plačati Lightning račun za %1$d sate. Ta nApplet želi plačati Lightning račun za %1$d satov. + + + + + Povezava z Nostrom se želi povezati z vašim nostr računom @@ -828,6 +860,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Šifriraj sporočilo berem tvoja zasebna sporočila + + + + Povezane aplikacije Prekliči vsa dovoljenja diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index d0f4586e4b..1b259496ac 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -27,6 +27,7 @@ Слика канала Референтни догађај није пронађен Није могуће дешифровати поруку + Групна слика Експлицитни садржај Obaveštenje relay-a @@ -697,6 +698,7 @@ Ovaj nApplet želi da pročita događaje sa vaših relay-a. Ovaj nApplet želi da koristi svoje privatno skladište. Ovaj nApplet želi da plati Lightning fakturu. + Ovaj nApplet želi da preuzme veb resurs. Ovaj nApplet želi da otpremi datoteku na vaš medija server. Ovaj nApplet želi da vam prikazuje obaveštenja. @@ -706,11 +708,24 @@ Ovaj nApplet želi da plati Lightning fakturu od %1$d sata. Ovaj nApplet želi da plati Lightning fakturu od %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d1c8634f7b..94bfad8500 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -25,6 +25,7 @@ Kanal bild Refererad händelse hittades inte Kunde inte dekryptera meddelandet + Grupp bild Explicit Innehåll Relay-meddelande @@ -808,6 +809,7 @@ Det här nApplet vill läsa händelser från dina reläer. Det här nApplet vill använda sin privata lagring. Det här nApplet vill betala en Lightning-faktura. + Det här nApplet vill hämta en webbresurs. Det här nApplet vill ladda upp en fil till din medieserver. Det här nApplet vill visa dig aviseringar. @@ -816,6 +818,15 @@ Det här nApplet vill betala en Lightning-faktura på %1$d sat. Det här nApplet vill betala en Lightning-faktura på %1$d sats. + + + + + Anslut till Nostr vill ansluta till ditt Nostr-konto @@ -851,6 +862,10 @@ kryptera ett meddelande läsa dina privata meddelanden + + + + Anslutna appar Återkalla alla behörigheter diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index aa5cfe2129..348d31edf4 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -25,6 +25,7 @@ Picha ya Kituo Tukio linalorejelewa halijapatikana Haikuweza kusimbua ujumbe + Picha ya Kikundi Maudhui Dhahiri Ilani ya Usambazaji @@ -693,6 +694,7 @@ nApplet hii inataka kusoma matukio kutoka kwa relay zako. nApplet hii inataka kutumia hifadhi yake ya kibinafsi. nApplet hii inataka kulipa ankara ya Lightning. + nApplet hii inataka kupata rasilimali ya wavuti. nApplet hii inataka kupakia faili kwenye seva yako ya media. nApplet hii inataka kukuonyesha arifa. @@ -701,11 +703,24 @@ nApplet hii inataka kulipa ankara ya Lightning ya sat %1$d. nApplet hii inataka kulipa ankara ya Lightning ya sats %1$d. + + + + + + + + + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 4d761af82f..98d03911c0 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -24,6 +24,7 @@ சேனல் படம் குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை செய்தியை மறைகுறியாக்க முடியவில்லை + குழுப் படம் வெளிப்படையான உள்ளடக்கம் relay அறிவிப்பு @@ -697,6 +698,7 @@ இந்த nApplet உங்கள் relays இலிருந்து நிகழ்வுகளை படிக்க விரும்புகிறது. இந்த nApplet அதன் தனிப்பட்ட சேமிப்பை பயன்படுத்த விரும்புகிறது. இந்த nApplet ஒரு Lightning இன்வாய்ஸை செலுத்த விரும்புகிறது. + இந்த nApplet ஒரு இணைய வளத்தை பெற விரும்புகிறது. இந்த nApplet உங்கள் மீடியா சேவையகத்தில் ஒரு கோப்பை பதிவேற்ற விரும்புகிறது. இந்த nApplet உங்களுக்கு அறிவிப்புகளை காட்ட விரும்புகிறது. @@ -705,11 +707,24 @@ இந்த nApplet %1$d sat-க்கான Lightning invoice செலுத்த விரும்புகிறது. இந்த nApplet %1$d sats-க்கான Lightning invoice செலுத்த விரும்புகிறது. + + + + + + + + + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 51743690da..0f3af6a697 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -22,6 +22,7 @@ รูปของ channel ไม่พบ event ที่อ้างอิง ไม่สามารถเข้ารหัสข้อความได้ + รูปภาพกลุ่ม เนื้อหาที่มีความรุนแรง การแจ้งเตือนจาก relay @@ -681,6 +682,7 @@ nApplet นี้ต้องการอ่านอีเวนต์จาก relay ของคุณ nApplet นี้ต้องการใช้พื้นที่จัดเก็บส่วนตัวของตัวเอง nApplet นี้ต้องการชำระใบแจ้งหนี้ Lightning + nApplet นี้ต้องการดึงข้อมูลทรัพยากรเว็บ nApplet นี้ต้องการอัปโหลดไฟล์ไปยังเซิร์ฟเวอร์สื่อของคุณ nApplet นี้ต้องการแสดงการแจ้งเตือนให้คุณ @@ -688,11 +690,24 @@ nApplet นี้ต้องการชำระใบแจ้งหนี้ Lightning เป็นจำนวน %1$d sats + + + + + + + + + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index d2db7f9a28..baa00ac0f6 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -24,6 +24,7 @@ Kanal Resmi Referanslı etkinlik bulunamadı Mesaj deşifre edilemedi + Grup Resmi Müstehcen İçerik relay Bildirimi @@ -697,6 +698,7 @@ Bu nApplet relay\'lerinden etkinlikleri okumak istiyor. Bu nApplet kendi özel depolamasını kullanmak istiyor. Bu nApplet bir Lightning faturası ödemek istiyor. + Bu nApplet bir web kaynağı getirmek istiyor. Bu nApplet medya sunucuna dosya yüklemek istiyor. Bu nApplet sana bildirim göstermek istiyor. @@ -705,11 +707,24 @@ Bu nApplet %1$d sat için bir Lightning faturası ödemek istiyor. Bu nApplet %1$d sat için bir Lightning faturası ödemek istiyor. + + + + + + + + + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index ee6c1f12d8..12fb2961de 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -30,6 +30,7 @@ Фото каналу Пов\'язану подію не знайдено Не вдалося розшифрувати повідомлення + Фото групи Відвертий вміст Повідомлення relay @@ -710,6 +711,7 @@ Цей nApplet хоче читати події з ваших relay. Цей nApplet хоче використовувати своє приватне сховище. Цей nApplet хоче оплатити Lightning-інвойс. + Цей nApplet хоче отримати веб-ресурс. Цей nApplet хоче завантажити файл на ваш медіасервер. Цей nApplet хоче показати вам сповіщення. @@ -720,11 +722,24 @@ Цей nApplet хоче оплатити Lightning-інвойс на %1$d sats. Цей nApplet хоче оплатити Lightning-інвойс на %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 516159170b..20ff611c65 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -25,6 +25,7 @@ Kanal rasmi Havola qilingan post topilmadi Xabarni shifrdan chiqarib bo\'lmadi + Guruh rasmi Uyatsiz kontent Relay bildirishnomasi @@ -700,6 +701,7 @@ Bu nApplet relaylaringizdan voqealarni o\'qishni xohlaydi. Bu nApplet o\'zining xususiy saqlash joyidan foydalanishni xohlaydi. Bu nApplet Lightning hisob-fakturasini to\'lashni xohlaydi. + Bu nApplet veb resursini olishni xohlaydi. Bu nApplet media serveringizga fayl yuklashni xohlaydi. Bu nApplet sizga bildirishnomalar ko\'rsatishni xohlaydi. @@ -708,11 +710,24 @@ Bu nApplet %1$d sat uchun Lightning hisob-fakturasini to\'lamoqchi. Bu nApplet %1$d sats uchun Lightning hisob-fakturasini to\'lamoqchi. + + + + + + + + + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 5fd44c6bb1..e25d036469 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -21,6 +21,7 @@ Ảnh kênh Không tìm thấy sự kiện được tham chiếu Không thể giải mã tin nhắn + Hình ảnh nhóm Nội dung người lớn Thông báo từ relay @@ -680,6 +681,7 @@ nApplet này muốn đọc sự kiện từ các relay của bạn. nApplet này muốn sử dụng bộ nhớ riêng tư của nó. nApplet này muốn thanh toán một hóa đơn Lightning. + nApplet này muốn tải một tài nguyên web. nApplet này muốn tải tệp lên máy chủ phương tiện của bạn. nApplet này muốn hiển thị thông báo cho bạn. @@ -687,11 +689,24 @@ nApplet này muốn thanh toán hóa đơn Lightning %1$d sats. + + + + + + + + + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 13e8aa6fdf..d5e0280a64 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -22,6 +22,7 @@ 频道图片 未找到相关事件 无法解密消息 + 群聊图片 露骨内容 中继通知 @@ -798,6 +799,7 @@ 此 nApplet 想要从您的中继读取事件。 此 nApplet 想要使用其私密存储。 此 nApplet 想要支付闪电发票。 + 此 nApplet 想要获取一个 web 资源。 此 nApplet 想要上传一个文件到您的媒体服务器。 此 nApplet 想要向您显示通知。 @@ -805,6 +807,15 @@ 此 nApplet 想要支付 %1$d sat 的闪电发票。 + + + + + 连接到 Nostr 想要连接到您的 Nostr 帐户 @@ -840,6 +851,10 @@ 加密消息 读取您的私信 + + + + 已连接的应用程序 撤销所有权限 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index f9fe34bf3f..b37cb2fc56 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -21,6 +21,7 @@ 頻道圖片 未找到相關事件 無法解密消息 + 羣聊圖片 明確內容 中继通知 @@ -696,6 +697,7 @@ 此 nApplet 想要从您的中继读取事件。 此 nApplet 想要使用其私密存储。 此 nApplet 想要支付闪电发票。 + 此 nApplet 想要获取一个 web 资源。 此 nApplet 想要上传一个文件到您的媒体服务器。 此 nApplet 想要向您显示通知。 @@ -703,11 +705,24 @@ 此 nApplet 想要支付 %1$d sat 的闪电发票。 + + + + + + + + + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 2bebbbb812..e6d9084701 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -21,6 +21,7 @@ 频道图片 未找到相关事件 无法解密消息 + 群聊图片 露骨内容 中继通知 @@ -696,6 +697,7 @@ 此 nApplet 想要从您的中继读取事件。 此 nApplet 想要使用其私密存储。 此 nApplet 想要支付闪电发票。 + 此 nApplet 想要获取一个 web 资源。 此 nApplet 想要上传一个文件到您的媒体服务器。 此 nApplet 想要向您显示通知。 @@ -703,11 +705,24 @@ 此 nApplet 想要支付 %1$d sat 的闪电发票。 + + + + + + + + + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 6c20edba21..f80e499011 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -22,6 +22,7 @@ 頻道圖片 未找到相關事件 無法解密消息 + 群組圖片 露骨內容 Relay 通知 @@ -690,6 +691,7 @@ 此 nApplet 想要從您的 relay 讀取事件。 此 nApplet 想要使用其私有儲存空間。 此 nApplet 想要支付一筆 Lightning 發票。 + 此 nApplet 想要擷取一個網路資源。 此 nApplet 想要上傳檔案至您的媒體伺服器。 此 nApplet 想要向您顯示通知。 @@ -697,11 +699,24 @@ 此 nApplet 想要支付一筆 %1$d sats 的 Lightning 發票。 + + + + + + + + + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bc61271482..f37c43ae9e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -25,6 +25,8 @@ Channel Image Referenced event not found Could not decrypt the message + + Decrypting… Group Picture Explicit Content Relay Notice @@ -312,6 +314,10 @@ This invite link is invalid or can\'t be opened with this account. This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link has been revoked and can no longer be used. Ask for a new one. + This invite link has expired and can no longer be used. Ask for a fresh link. + Community name is only revealed after you join + Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join. + Relays this invite will contact: %1$s Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. @@ -327,6 +333,10 @@ Delete channel? Delete #%1$s? This can\'t be undone and the channel can\'t be recreated with the same id. Delete + Leave community + Leave community? + Leave %1$s? It is removed from this account\'s list and stops syncing on your devices. The community is not notified and you are not removed from its member roster. Messages you can no longer decrypt may be unrecoverable, and you can only return with a new invite. + You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again. Where this community\'s encrypted planes are published and read. %1$s is typing… %1$s and %2$s are typing… @@ -371,6 +381,13 @@ Remove member? This rotates the community\'s encryption key so this member can no longer read anything sent afterwards. Everyone else is re-keyed automatically. This can\'t be undone. Remove + Roles… + Assign roles + Pick every role this member should hold. Unchecking a role removes it. + Save + You don\'t outrank this member + No roles you can assign + Could not update this member\'s roles. Owner Admin Banned @@ -841,6 +858,8 @@ This nApplet wants to read events from your relays. This nApplet wants to use its private storage. This nApplet wants to pay a Lightning invoice. + + ⚠ This nApplet wants to pay a Lightning invoice that specifies NO amount — the payee decides how much is taken. Only allow this if you trust it. This nApplet wants to fetch a web resource. This nApplet wants to upload a file to your media server. This nApplet wants to show you notifications. @@ -849,6 +868,60 @@ This nApplet wants to pay a Lightning invoice for %1$d sat. This nApplet wants to pay a Lightning invoice for %1$d sats. + + + follows %1$d new account + follows %1$d new accounts + + + UNFOLLOWS %1$d account + UNFOLLOWS %1$d accounts + + + adds %1$d relay + adds %1$d relays + + + REMOVES %1$d relay + REMOVES %1$d relays + + + mutes %1$d more person + mutes %1$d more people + + + UNMUTES %1$d person + UNMUTES %1$d people + + + ⚠ This follows %1$s. + ⚠ This UNFOLLOWS %1$s. + ⚠ This mutes %1$s. + ⚠ This UNMUTES %1$s. + + ⚠ This rewrites your follow list: %1$s. + ⚠ This rewrites your relay list: %1$s. Your posts and reads move with it. + ⚠ This rewrites your mute list: %1$s. Muted words and hashtags are not shown here — tap “Show Event” for the full list. + %1$s and %2$s + + This republishes your existing list unchanged. + + + ⚠ This writes a list of %1$d entry. Amethyst has no cached copy to compare against. + ⚠ This writes a list of %1$d entries. Amethyst has no cached copy to compare against. + + + ⚠ This requests deletion of %1$d of your events. + ⚠ This requests deletion of %1$d of your events. + + + Carries %1$d tag. Tap “Show Event” to see exactly what would be signed. + Carries %1$d tags. Tap “Show Event” to see exactly what would be signed. + Connect to Nostr wants to connect to your Nostr account @@ -887,6 +960,14 @@ encrypt a message read your private messages + + read your private messages with %1$s + + Always allow for %1$s + + Amethyst could not decrypt this message. It may not be addressed to this account. + + Messages with Connected Apps @@ -1569,6 +1650,7 @@ This server requires payment to upload: %1$s Payment required %1$s charges a lightning payment to store this file. Pay from your connected wallet to continue. + %1$s says: “%2$s” Pay Pay %1$d sat @@ -2545,6 +2627,8 @@ Could not redeem Cashu Mint provided the following error message: %1$s + Unsafe Cashu mint address + Amethyst did not contact this token\'s mint. %1$s Cashu Received %1$s sats were sent to your wallet. (Fees: %2$s sats) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilderTest.kt new file mode 100644 index 0000000000..bb5344e8c6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilderTest.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.model.nip46Signer + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The decrypt-consent contract: a user asked to expose a private message must be able to see WHOSE + * conversation it is and WHAT it says. Before this, both were empty for every decrypt request, so the + * dialog skipped its whole preview block and offered "Allow always" over a blank body. + */ +class Nip46ConsentInfoBuilderTest { + private val alice = "1".repeat(64) + private val coordinate = "nip46:${"a".repeat(64)}:${"c".repeat(64)}" + private val account = SignerFace(name = "Me", picture = null, pubKey = "a".repeat(64)) + + private val decryptFailedText = "Amethyst could not decrypt this message." + + private fun strings() = + Nip46ConsentStrings( + opLabel = { op -> + when (op) { + is NostrSignerOp.DecryptFrom -> "read your private messages with ${op.counterparty.take(6)}" + NostrSignerOp.Decrypt -> "read your private messages" + else -> "do something" + } + }, + allowAlwaysFor = { "Always allow for $it" }, + decryptFailed = decryptFailedText, + ) + + private suspend fun build( + request: BunkerRequest, + op: NostrSignerOp = NostrSignerOp.Decrypt, + faceOf: (String) -> SignerFace = { SignerFace(name = null, picture = null, pubKey = it) }, + decrypt: suspend (BunkerRequest) -> String? = { "the plaintext" }, + ): SignerConsentInfo = + Nip46ConsentInfoBuilder.build( + coordinate = coordinate, + title = "Some App", + iconUrl = null, + op = op, + request = request, + account = account, + faceOf = faceOf, + strings = strings(), + decrypt = decrypt, + ) + + @Test + fun decryptConsentCarriesTheCounterpartyAndThePlaintext() = + runTest { + val info = build(BunkerRequestNip44Decrypt("1", alice, "ciphertext")) + + assertEquals(alice, info.counterpartyPubKey) + assertTrue("counterparty label must not be empty", !info.counterpartyName.isNullOrBlank()) + assertEquals("the plaintext", info.contentPreview) + // The dialog renders its preview block only when one of these is non-blank. + assertTrue("the dialog must have content to show", info.contentPreview.isNotBlank() || info.rawData.isNotBlank()) + } + + @Test + fun anUncachedCounterpartyFallsBackToAShortenedNpubNeverToNothing() = + runTest { + val info = build(BunkerRequestNip44Decrypt("1", alice, "ct"), faceOf = { SignerFace(null, null, it) }) + + val name = info.counterpartyName + assertNotNull(name) + assertTrue("expected an npub fallback, got '$name'", name!!.startsWith("npub")) + } + + @Test + fun aBlankCachedNameStillFallsBackRatherThanShowingAnEmptyLabel() = + runTest { + val info = build(BunkerRequestNip44Decrypt("1", alice, "ct"), faceOf = { SignerFace(" ", null, it) }) + + assertTrue(!info.counterpartyName.isNullOrBlank()) + } + + @Test + fun anUndecryptableMessageStillProducesAPopulatedDialog() = + runTest { + val info = build(BunkerRequestNip04Decrypt("1", alice, "garbage"), decrypt = { error("bad ciphertext") }) + + assertEquals(decryptFailedText, info.contentPreview) + assertTrue("the counterparty is still shown", !info.counterpartyName.isNullOrBlank()) + assertTrue("the dialog must not be blank", info.contentPreview.isNotBlank()) + } + + @Test + fun aSignerThatReturnsNothingIsTreatedAsAFailureNotAsAnEmptyDialog() = + runTest { + val blank = build(BunkerRequestNip44Decrypt("1", alice, "ct"), decrypt = { " " }) + assertEquals(decryptFailedText, blank.contentPreview) + + val none = build(BunkerRequestNip44Decrypt("1", alice, "ct"), decrypt = { null }) + assertEquals(decryptFailedText, none.contentPreview) + } + + /** A signer that never answers must not wedge the prompt — runTest fast-forwards the timeout. */ + @Test + fun aHangingSignerTimesOutIntoTheFailureTextInsteadOfBlockingThePrompt() = + runTest { + val info = build(BunkerRequestNip44Decrypt("1", alice, "ct"), decrypt = { awaitCancellation() }) + + assertEquals(decryptFailedText, info.contentPreview) + } + + @Test + fun aLongPlaintextIsTruncatedInlineAndOfferedInFullBehindTheToggle() = + runTest { + val long = "x".repeat(500) + val info = build(BunkerRequestNip44Decrypt("1", alice, "ct"), decrypt = { long }) + + assertEquals(Nip46ConsentInfoBuilder.PREVIEW_MAX_CHARS, info.contentPreview.length) + assertEquals(long, info.rawData) + } + + @Test + fun decryptOffersANarrowerPerCounterpartyGrantAlongsideTheBroadOne() = + runTest { + val info = build(BunkerRequestNip44Decrypt("1", alice, "ct")) + + assertEquals(NostrSignerOp.DecryptFrom(alice), info.narrowOp) + assertTrue("the narrow button needs a label", !info.narrowOpLabel.isNullOrBlank()) + // The broad op is still what the dialog's "Always allow" grants. + assertEquals(NostrSignerOp.Decrypt, info.op) + // The headline names the counterparty rather than saying "your private messages". + assertTrue(info.operationSummary.contains("with")) + } + + @Test + fun signRequestsAreUnchangedAndCarryNoCounterparty() = + runTest { + val request = BunkerRequestSign("1", EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hello")) + val info = build(request, op = NostrSignerOp.SignKind(1)) + + assertEquals("hello", info.contentPreview) + assertTrue("sign still shows its JSON", info.rawData.contains("\"kind\"")) + assertNull(info.counterpartyName) + assertNull(info.counterpartyPubKey) + assertNull(info.narrowOp) + assertNotNull(info.previewTemplate) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentSafetyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentSafetyTest.kt new file mode 100644 index 0000000000..d1564aeac8 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentSafetyTest.kt @@ -0,0 +1,228 @@ +/* + * 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.uploads.blossom + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * BUD-07 is "a server tells the client to pay an invoice it chose". Everything + * about the amount, the retry loop and the on-screen justification is therefore + * attacker-influenced. These tests pin the four limits that keep a hostile paid + * Blossom server from draining the user's NWC wallet. + */ +class BlossomPaymentSafetyTest { + // Real BOLT-11 invoices: getAmountInSats verifies the bech32 checksum, so + // these cannot be fabricated. + + /** 1,000 sats — a plausible per-blob storage fee, under the cap. */ + private val invoice1000Sats = + "lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v" + + /** 42 sats. */ + private val invoice42Sats = + "lnbc420n1pns600jdqjfah8wctjvss0p8at5ynp4qtfc238rdkzsj26waa3l8zgag9damzltzsqcrlscj9gvpc7ch2qs2pp5ks03qwm8laa78hnh0xy0p78l6wmyuj3zfqas3gzc2a52lj2zrmtqsp539528j3tvvfzpk8n5v966zccvj3pq2l3etxqxh5qsp8emh29yw3q9qyysgqcqpcxqyz5vqrzjqvdnqyc82a9maxu6c7mee0shqr33u4z9z04wpdwhf96gxzpln8jcrapyqqqqqqp2rcqqqqlgqqqqqzsq2qrzjqw9fu4j39mycmg440ztkraa03u5qhtuc5zfgydsv6ml38qd4azymlapyqqqqqqqp9sqqqqlgqqqq86qqjqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8wwxrgayyqq2mgqqqqqqqqqqqqqqqqq2quc90y7tgfxuauh0vjfvhxjgektaycfesne76jcuk4u9mt6a9l39pddzk3muwy03sjvfk0w8390xxnpzu2656jf2l73ya59ye2yx9aaqpdgxmaq" + + /** 100,000 sats — an order of magnitude past any real storage fee. */ + private val invoice100kSats = + "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" + + /** No amount in the HRP: the payee decides how much to take. */ + private val invoiceAmountless = + "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql" + + private fun challenge( + invoice: String, + reason: String? = null, + ) = BlossomPaymentRequired(lightning = invoice, reason = reason) + + @Before + fun reset() { + InFlightInvoices.clear() + } + + // ------------------------------------------------------------------ + // (a) amount cap + "what you saw is what you pay" + // ------------------------------------------------------------------ + + @Test + fun amountUnderCapAndMatchingTheDialogIsAccepted() { + assertEquals( + BlossomPaymentHandler.AmountCheck.Ok(1_000L), + BlossomPaymentHandler.checkAmount(challenge(invoice1000Sats), shownSats = 1_000L), + ) + assertEquals( + BlossomPaymentHandler.AmountCheck.Ok(42L), + BlossomPaymentHandler.checkAmount(challenge(invoice42Sats), shownSats = 42L), + ) + } + + @Test + fun overCapInvoiceIsRefused() { + val check = BlossomPaymentHandler.checkAmount(challenge(invoice100kSats), shownSats = 100_000L) + assertTrue("100k sats must be over the cap, got $check", check is BlossomPaymentHandler.AmountCheck.OverCap) + assertEquals(100_000L, (check as BlossomPaymentHandler.AmountCheck.OverCap).sats) + // The cap must actually bite below this invoice, otherwise the test is vacuous. + assertTrue(BlossomPaymentHandler.MAX_PAYMENT_SATS < 100_000L) + } + + @Test + fun amountThatDoesNotMatchTheDialogIsRefused() { + // The dialog said 1 sat; the invoice wants 1,000. + val check = BlossomPaymentHandler.checkAmount(challenge(invoice1000Sats), shownSats = 1L) + assertEquals(BlossomPaymentHandler.AmountCheck.Mismatch(1L, 1_000L), check) + } + + @Test + fun amountlessInvoiceIsRefused() { + assertEquals( + BlossomPaymentHandler.AmountCheck.Amountless, + BlossomPaymentHandler.checkAmount(challenge(invoiceAmountless), shownSats = null), + ) + // ...and it cannot be smuggled through by claiming a shown amount either. + assertEquals( + BlossomPaymentHandler.AmountCheck.Amountless, + BlossomPaymentHandler.checkAmount(challenge(invoiceAmountless), shownSats = 1L), + ) + } + + @Test + fun everyRefusalCarriesAUserFacingReason() { + listOf( + BlossomPaymentHandler.checkAmount(challenge(invoice100kSats), 100_000L), + BlossomPaymentHandler.checkAmount(challenge(invoice1000Sats), 1L), + BlossomPaymentHandler.checkAmount(challenge(invoiceAmountless), null), + ).forEach { + assertTrue("refusal must explain itself: $it", BlossomPaymentHandler.refusalReason(it).isNotBlank()) + } + } + + // ------------------------------------------------------------------ + // (b) the re-prompt loop is bounded + // ------------------------------------------------------------------ + + @Test + fun aTargetPromptsAtMostOncePerUserAction() { + val ledger = PaymentPromptLedger() + ledger.beginUserAction() + + assertTrue("first 402 must prompt", ledger.shouldPrompt("hash1", "https://paid.example.com")) + // The server pockets the preimage and answers 402 again, forever. + repeat(50) { + assertFalse( + "a server replying 402 after payment must not re-prompt", + ledger.shouldPrompt("hash1", "https://paid.example.com"), + ) + } + } + + @Test + fun otherTargetsInTheSameActionStillGetTheirOnePrompt() { + val ledger = PaymentPromptLedger() + ledger.beginUserAction() + + assertTrue(ledger.shouldPrompt("hash1", "https://a.example.com")) + assertTrue(ledger.shouldPrompt("hash1", "https://b.example.com")) + assertTrue(ledger.shouldPrompt("hash2", "https://a.example.com")) + assertFalse(ledger.shouldPrompt("hash1", "https://a.example.com")) + } + + @Test + fun aFreshUserActionRestoresTheBudget() { + val ledger = PaymentPromptLedger() + ledger.beginUserAction() + assertTrue(ledger.shouldPrompt("hash1", "https://a.example.com")) + assertFalse(ledger.shouldPrompt("hash1", "https://a.example.com")) + + ledger.beginUserAction() // the user tapped mirror again, deliberately + assertTrue(ledger.shouldPrompt("hash1", "https://a.example.com")) + } + + // ------------------------------------------------------------------ + // (c) a timed-out payment cannot be paid a second time + // ------------------------------------------------------------------ + + @Test + fun aTimedOutInvoiceCannotBeSentAgain() { + // pay() claims the invoice before handing it to the wallet... + assertTrue(InFlightInvoices.tryClaim(invoice1000Sats)) + // ...and on timeout deliberately does NOT release it, because NIP-47 has + // no cancel and the payment may still settle. + assertTrue(InFlightInvoices.isAwaiting(invoice1000Sats)) + assertFalse( + "an unresolved invoice must never be sent to the wallet twice", + InFlightInvoices.tryClaim(invoice1000Sats), + ) + } + + @Test + fun aResolvedInvoiceReleasesTheClaim() { + assertTrue(InFlightInvoices.tryClaim(invoice1000Sats)) + InFlightInvoices.release(invoice1000Sats) // wallet answered: paid, or explicitly failed + assertFalse(InFlightInvoices.isAwaiting(invoice1000Sats)) + assertTrue(InFlightInvoices.tryClaim(invoice1000Sats)) + } + + @Test + fun claimsAreScopedToTheInvoice() { + assertTrue(InFlightInvoices.tryClaim(invoice1000Sats)) + assertTrue("a different invoice is unaffected", InFlightInvoices.tryClaim(invoice42Sats)) + } + + // ------------------------------------------------------------------ + // (d) the server's X-Reason cannot impersonate our own wording + // ------------------------------------------------------------------ + + @Test + fun reasonIsStrippedOfControlCharactersAndNewlines() { + val hostile = challenge(invoice1000Sats, reason = "Storage fee\n\n\n\n\n\n\n\n\n\n\n\n\nPay 1 sat") + val clean = hostile.sanitizedReason()!! + + assertFalse("no newlines", clean.contains('\n')) + assertFalse("no carriage returns", clean.contains('\r')) + assertFalse("no control characters", clean.any { it.isISOControl() }) + assertEquals("Storage fee Pay 1 sat", clean) + } + + @Test + fun reasonIsClampedInLength() { + val long = challenge(invoice1000Sats, reason = "A".repeat(5_000)) + val clean = long.sanitizedReason()!! + assertTrue("clamped, got ${clean.length}", clean.length <= BlossomPaymentRequired.MAX_REASON_LENGTH + 1) + } + + @Test + fun reasonBidiOverridesAreRemoved() { + val clean = challenge(invoice1000Sats, reason = "fee ‮reversed‬ text").sanitizedReason()!! + assertFalse(clean.contains('‮')) + assertFalse(clean.contains('‬')) + } + + @Test + fun blankOrAbsentReasonBecomesNull() { + assertEquals(null, challenge(invoice1000Sats, reason = null).sanitizedReason()) + assertEquals(null, challenge(invoice1000Sats, reason = "\n\t ").sanitizedReason()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt index 3410d1c55a..82a772f8ef 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -45,28 +46,51 @@ class FilterRelayGroupStateTest { private val relaySignKey = "b".repeat(64) private val sig = "0".repeat(128) - private val stateKinds = + private val metadataKinds = listOf( GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, ) + private val pinKinds = listOf(GroupPinnedEvent.KIND) @Test - fun `with no pins it is a single host d-scoped state filter and no message window`() { + fun `with no pins it is host d-scoped state filters and no message window`() { val channel = RelayGroupChannel(groupId) val filters = filterRelayGroupState(channel, since = null) - val f = filters.single() - assertEquals(relayA, f.relay) - assertEquals(stateKinds, f.filter.kinds) - assertEquals(listOf("g1"), f.filter.tags!!["d"]) - assertNull("state is #d-scoped, never #h — the message window is the tail/pager's job", f.filter.tags!!["h"]) - assertNull("state carries no message-window kinds (9/poll), so no limit either", f.filter.limit) - assertNull(f.filter.until) + assertEquals(2, filters.size) + filters.forEach { f -> + assertEquals(relayA, f.relay) + assertEquals(listOf("g1"), f.filter.tags!!["d"]) + assertNull("state is #d-scoped, never #h — the message window is the tail/pager's job", f.filter.tags!!["h"]) + assertNull("state carries no message-window kinds (9/poll), so no limit either", f.filter.limit) + assertNull(f.filter.until) + } + assertEquals(metadataKinds, filters[0].filter.kinds) + assertEquals(pinKinds, filters[1].filter.kinds) + } + + /** + * Regression: relay29-family relays (0xchat's `groups.0xchat.com`) answer a filter that mixes the + * 39000-39003 metadata kinds with any other kind — 39005 pins included — with + * `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`, dropping the WHOLE REQ. + * The group then never learns its name, roster or the user's own membership, so it renders as a raw + * id and offers "Join" to somebody the relay already lists as an admin. Keep the two apart. + */ + @Test + fun `pins are never mixed into the metadata filter`() { + val channel = RelayGroupChannel(groupId) + + filterRelayGroupState(channel, since = null).forEach { f -> + val kinds = f.filter.kinds ?: return@forEach + assertFalse( + "39005 must not share a filter with the 39000-39003 metadata block: $kinds", + kinds.contains(GroupPinnedEvent.KIND) && kinds.any { it in metadataKinds }, + ) + } } @Test @@ -85,7 +109,7 @@ class FilterRelayGroupStateTest { ) val filters = filterRelayGroupState(channel, since = null) - assertEquals(2, filters.size) + assertEquals(3, filters.size) val pinFilter = filters.first { it.filter.ids != null } assertEquals(relayA, pinFilter.relay) @@ -93,10 +117,12 @@ class FilterRelayGroupStateTest { assertNull("pinned bodies are fetched by id, so no kinds", pinFilter.filter.kinds) assertNull("pinned events are immutable, so no since either", pinFilter.filter.since) - // The state filter is still present and still carries no #h message window. - val stateFilter = filters.first { it.filter.ids == null } - assertEquals(stateKinds, stateFilter.filter.kinds) - assertTrue(stateFilter.filter.tags!!.containsKey("d")) - assertNull(stateFilter.filter.tags!!["h"]) + // The state filters are still present and still carry no #h message window. + val stateFilters = filters.filter { it.filter.ids == null } + assertEquals(listOf(metadataKinds, pinKinds), stateFilters.map { it.filter.kinds }) + stateFilters.forEach { + assertTrue(it.filter.tags!!.containsKey("d")) + assertNull(it.filter.tags!!["h"]) + } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt index 2434214f12..c763a9bd6f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt @@ -65,24 +65,44 @@ class RelayGroupFilterBuildersTest { // --- State (always-on): one #d filter per host relay, batching that relay's group ids --- @Test - fun `state batches one d-filter per host relay`() { + fun `state batches d-filters per host relay, metadata and pins kept apart`() { val filters = buildRelayGroupStateFilters(joined) { null } - assertEquals(2, filters.size) + // two relays x (metadata + pins) + assertEquals(4, filters.size) - val a = filters.single { it.relay == relayA } - assertEquals(RELAY_GROUP_STATE_KINDS, a.filter.kinds) - assertEquals(setOf("g1", "g2"), a.filter.tags!!["d"]!!.toSet()) - assertNull("state is #d-scoped, never #h", a.filter.tags!!["h"]) + val a = filters.filter { it.relay == relayA } + assertEquals(listOf(RELAY_GROUP_METADATA_KINDS, RELAY_GROUP_PIN_KINDS), a.map { it.filter.kinds }) + a.forEach { + assertEquals(setOf("g1", "g2"), it.filter.tags!!["d"]!!.toSet()) + assertNull("state is #d-scoped, never #h", it.filter.tags!!["h"]) + } - val b = filters.single { it.relay == relayB } - assertEquals(listOf("g3"), b.filter.tags!!["d"]) + val b = filters.filter { it.relay == relayB } + b.forEach { assertEquals(listOf("g3"), it.filter.tags!!["d"]) } + } + + /** + * Regression: relay29-family relays (0xchat's `groups.0xchat.com`) reject a filter mixing the + * 39000-39003 metadata kinds with any other kind (39005 pins included) with + * `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"` and drop the whole REQ — + * so every joined group on such a relay silently loses its name, roster and membership. + */ + @Test + fun `state never mixes pins into the metadata filter`() { + buildRelayGroupStateFilters(joined) { null }.forEach { f -> + val kinds = f.filter.kinds!! + assertFalse( + "39005 must not share a filter with the 39000-39003 metadata block: $kinds", + kinds.contains(GroupPinnedEvent.KIND) && kinds.any { it in RELAY_GROUP_METADATA_KINDS }, + ) + } } @Test fun `state applies the per-relay since`() { val filters = buildRelayGroupStateFilters(joined) { relay -> if (relay == relayA) 111L else null } - assertEquals(111L, filters.single { it.relay == relayA }.filter.since) - assertNull(filters.single { it.relay == relayB }.filter.since) + filters.filter { it.relay == relayA }.forEach { assertEquals(111L, it.filter.since) } + filters.filter { it.relay == relayB }.forEach { assertNull(it.filter.since) } } // --- Joined chat tail (always-on): batched #h per relay, time floor, NO per-group limit --- diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index 130414c7c0..db81032a02 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap /** * `amy bunker [--relay URL[,URL…]] [--secret S] [--perms P] [--interactive] [--timeout SECS]` @@ -94,11 +95,25 @@ object BunkerCommand { ) : Nip46RequestAuthorizer { private val promptLock = Mutex() + /** + * Clients that presented the right secret in this process's lifetime. The CLI bunker keeps no + * state on disk, so pairing is in-memory: a client must `connect` once per `amy bunker` run + * before it can read the hosted identity. + */ + private val paired = ConcurrentHashMap.newKeySet() + + /** + * Ungated (the headless default hosting the operator's own key) everything is open, matching + * [authorize]. Gated, the identity reads require a `connect` first. + */ + override suspend fun isPaired(clientPubKey: HexKey): Boolean = !gated || clientPubKey in paired + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, ): Nip46ConnectDecision = if (request.secret == secret) { + paired.add(clientPubKey) Nip46ConnectDecision.Accept(BunkerRequestProcessor.ACK) } else { Nip46ConnectDecision.Reject("invalid secret") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index b96c3fdd1b..16fc1b2757 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -59,7 +59,18 @@ object ConcordModCommands { state.roles.map { (id, r) -> mapOf("id" to id, "name" to r.name, "position" to r.position, "permissions" to r.permissions) }, - "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray()).toList(), + // The role-holder roster AFTER the authority fixpoint, so a grant that was + // published but dropped on fold (granter didn't outrank the role or the member) + // is visibly absent here rather than looking like it landed. + "grants" to + state.authority.roleHolders().sorted().map { member -> + mapOf( + "member" to member, + "rank" to state.authority.rank(member), + "roles" to state.authority.rolesFor(member).map { it.name }, + ) + }, + "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray(), sc.owner).toList(), ), ) return 0 @@ -84,7 +95,7 @@ object ConcordModCommands { val (cp, editions) = load(ctx, sc) val roleId = RandomInstance.bytes(32) val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) - val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now()) + val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now(), owner = sc.owner) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position) + RawEventSupport.ackFields(ack)) @@ -108,7 +119,7 @@ object ConcordModCommands { ctx.prepare() val member = ctx.requireUserHex(userRef) val (cp, editions) = load(ctx, sc) - val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now()) + val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now(), owner = sc.owner) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack)) @@ -146,9 +157,9 @@ object ConcordModCommands { val cid = sc.communityId.hexToByteArray() val wrap = if (ban) { - ConcordModeration.ban(ctx.signer, cp, cid, member, editions, TimeUtils.now()) + ConcordModeration.ban(ctx.signer, cp, cid, member, editions, TimeUtils.now(), owner = sc.owner) } else { - ConcordModeration.unban(ctx.signer, cp, cid, member, editions, TimeUtils.now()) + ConcordModeration.unban(ctx.signer, cp, cid, member, editions, TimeUtils.now(), owner = sc.owner) } val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt index 8881a5e36b..6102eb27f5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt @@ -59,7 +59,8 @@ object CashuMintCommands { val url = args.positional(0, "mint-url") args.rejectUnknown() return try { - val dto = MintHttpClient(url) { okhttp }.info() + // userConfigured: the operator typed this URL on the command line. + val dto = MintHttpClient(url, userConfigured = true) { okhttp }.info() Output.emit( mapOf( "mint_url" to url, @@ -82,7 +83,7 @@ object CashuMintCommands { val url = args.positional(0, "mint-url") args.rejectUnknown() return try { - val dto = MintHttpClient(url) { okhttp }.info(force = true) + val dto = MintHttpClient(url, userConfigured = true) { okhttp }.info(force = true) Output.emit(mapOf("mint_url" to url, "mint_info" to dto)) 0 } catch (e: MintHttpException) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 742c689a0b..4847685fa0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.Guestbook import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction @@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink @@ -52,6 +54,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.TimeUtils /** One decrypted, verified Concord channel message projected for display. */ data class ConcordChatMessage( @@ -350,6 +353,23 @@ object ConcordActions { /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) + /** + * Reduces an invite URL to the domain-agnostic bare `#` form + * stored as an entry's `invite_ref` (the stranded-recovery anchor). Null if the + * link is unparseable. + */ + fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url) + + /** + * Merges a stranded membership forward onto a higher-epoch [bundle] resolved at + * its own stored invite link, or null when there is nothing to recover. See + * [ConcordStrandedRecovery]. + */ + fun recoverStranded( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle) + /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ fun openBundle( bundleEvent: Event, @@ -358,14 +378,16 @@ object ConcordActions { /** * Resolves every event fetched at an invite's addressable coordinate into one - * [InviteBundleStatus] (live / revoked / unreadable / absent) per CORD-05 §2, so a - * redeeming client honours a `vsk=9` revocation tombstone and reports why a link - * can't be opened instead of retrying blindly. + * [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05 + * §2, so a redeeming client honours a `vsk=9` revocation tombstone and an + * `expires_at` in the past, and reports why a link can't be opened instead of + * retrying blindly. [nowMs] is unix milliseconds. */ fun classifyInvite( wraps: List, token: ByteArray, - ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token) + nowMs: Long = TimeUtils.nowMillis(), + ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs) /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 31c25887cd..3e3e9b1de0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer @@ -55,13 +58,40 @@ import kotlinx.serialization.builtins.serializer * act under so the fold can verify the chain terminates at the owner. */ object ConcordModeration { - /** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */ + /** + * The current head of [entityId] within [current], or null if the entity has no + * editions yet — **the authority-gated head**, i.e. the same edition a reader would + * fold to, resolved against the community's [owner]. + * + * Two traps live here, and both need the fold: + * + * 1. [current] arrives in **wrap-arrival order**, which is not chain order — so the + * first matching edition is whichever one a relay happened to deliver first, not + * the newest. Chaining off that stale edition forks the chain at an already-used + * version, silently dropping the change. + * 2. The *ungated* structural tip may be an edition every reader **rejects**. Building + * on it does two kinds of damage: the rogue's version number is inflated into every + * honest edition that follows, and — for a replaced document like the banlist — + * the rogue's *content* is read as the current state and re-published under an + * authorized signature. That launders the attack: an unauthorized empty banlist + * becomes an owner-signed one the moment the owner bans anybody else. + * + * Armada's writers chain off `folded.heads` (its `pickHead` gated pick) for exactly + * this reason; [ConcordCommunityState.authorizedHeads] is the same notion here. + */ + private fun headOf( + current: List, + entityId: ByteArray, + owner: HexKey, + ): ControlEdition? = ConcordCommunityState.authorizedHeads(current, owner)[entityId.toHexKey()]?.known + + /** version/prevHash to chain onto the current head of [entityId], or genesis. */ private fun versioning( current: List, - kind: ControlEntityKind, entityId: ByteArray, + owner: HexKey, ): Pair { - val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) } + val head = headOf(current, entityId, owner) return if (head != null) (head.version + 1) to head.hash else 0L to null } @@ -92,8 +122,9 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation? = null, + owner: HexKey, ): Event { - val (version, prev) = versioning(current, ControlEntityKind.ROLE, roleId) + val (version, prev) = versioning(current, roleId, owner) val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role) return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) } @@ -113,8 +144,9 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation? = null, + owner: HexKey, ): Event { - val (version, prev) = versioning(current, ControlEntityKind.CHANNEL, channelId) + val (version, prev) = versioning(current, channelId, owner) val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel) return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) } @@ -133,8 +165,9 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation? = null, + owner: HexKey, ): Event { - val (version, prev) = versioning(current, ControlEntityKind.METADATA, communityId) + val (version, prev) = versioning(current, communityId, owner) val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata) return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation) } @@ -149,9 +182,10 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation? = null, + owner: HexKey, ): Event { val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray()) - val (version, prev) = versioning(current, ControlEntityKind.GRANT, entityId) + val (version, prev) = versioning(current, entityId, owner) val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds)) return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation) } @@ -165,7 +199,8 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation? = null, - ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) + member.lowercase(), current, createdAt, citation) + owner: HexKey, + ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId, owner) + member.lowercase(), current, createdAt, citation, owner) /** Removes [member] from the banlist. */ suspend fun unban( @@ -176,17 +211,23 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation? = null, - ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) - member.lowercase(), current, createdAt, citation) + owner: HexKey, + ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId, owner) - member.lowercase(), current, createdAt, citation, owner) - /** The current banlist union across the head editions (lowercase hex). */ + /** + * The current banlist union across the head editions (lowercase hex). + * + * Read through the [AuthorityResolver] rather than by decoding the head's content directly, so + * this is the *honored* banlist: the resolver heals concurrent forks into the union (CORD-04 §4 + * re-heal) and drops entries whose signer did not outrank them (§3's rank rule, enforced as a + * delta rule). Decoding the raw head instead would make every ban/unban we author re-publish + * entries our own fold refuses — laundering an unauthorized ban into a list signed by us. + */ fun currentBanned( current: List, communityId: ByteArray, - ): Set { - val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) - val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) } - return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet() - } + owner: HexKey, + ): Set = AuthorityResolver.resolve(current, owner).bannedMembers() private suspend fun setBanlist( actor: NostrSigner, @@ -196,9 +237,10 @@ object ConcordModeration { current: List, createdAt: Long, citation: AuthorityCitation?, + owner: HexKey, ): Event { val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) - val (version, prev) = versioning(current, ControlEntityKind.BANLIST, entityId) + val (version, prev) = versioning(current, entityId, owner) val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted()) return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 22024d73f3..98096f4ce8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -55,11 +55,31 @@ data class ConcordPlaneSub( * ([channelPlaneSubs]). */ object ConcordSubscriptionPlanner { - /** Control-plane subscriptions for every joined community (known from the entry alone). */ + /** + * Control-plane subscriptions for every joined community (known from the entry alone) — at + * the current epoch **plus** every prior epoch the account still holds a root for. + * + * The prior-epoch Control Planes are what a CORD-06 Refounding's compacted head is checked + * against: the rotator picks which edition per entity survives the rotation, so without the + * old planes a client has no memory of the versions it already folded and a rotator can roll + * an entity backwards (restore a revoked role, clear a banlist) with genuine signatures. See + * `ConcordCommunitySession.historicalControlPlaneAddresses`. + */ fun controlPlaneSubs(entries: List): List = - entries.map { e -> - val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) - ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays)) + entries.flatMap { e -> + val communityId = e.id.hexToByteArray() + val relays = normalize(e.relays) + val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), communityId, e.rootEpoch) + val historical = + e.heldRoots + .filter { it.epoch < e.rootEpoch } + .sortedByDescending { it.epoch } + .take(ConcordActions.MAX_BACKFILL_EPOCHS) + .mapNotNull { held -> + val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityId, held.epoch) }.getOrNull() ?: return@mapNotNull null + ConcordPlaneSub(channelId = null, pubKeyHex = key.publicKeyHex, relays = relays) + } + listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = relays)) + historical } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index 1eb84daa24..ff7d41797e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -136,6 +136,14 @@ class Nip46PermissionAuthorizer( if (shouldWrite) ledger.updateLastUsed(coordinate, now) } + /** + * A client is paired exactly when the ledger holds a trust level for its coordinate — which is + * what a successful `connect` writes (and what "Forget" removes). Because it is persisted, a + * client stays paired across restarts, so a returning app that does not re-`connect` still reads + * the identity it was already granted. + */ + override suspend fun isPaired(clientPubKey: HexKey): Boolean = ledger.hasPolicy(coordinateFor(clientPubKey)) + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, @@ -187,16 +195,23 @@ class Nip46PermissionAuthorizer( // sign/encrypt/decrypt, so this branch is a safety net). val op = request.toSignerOp() ?: return true val coordinate = coordinateFor(clientPubKey) + // A decrypt request also carries a narrower op ("decrypt messages from THIS counterparty"). + // A standing narrow grant satisfies the request without widening the broad one. + val narrowOp = request.toNarrowSignerOp() val allowed = when (ledger.decide(coordinate, op)) { NostrOpDecision.ALLOW -> true + // An explicit DENY on the broad op is final — a narrow grant never overrides it. NostrOpDecision.DENY -> false - // ASK: honor a live session grant first, otherwise prompt the user (if wired). No - // prompt → deny, so a headless signer only ever performs pre-granted operations. + // ASK: honor a live session grant first, then any narrower standing/session grant, + // otherwise prompt the user (if wired). No prompt → deny, so a headless signer only + // ever performs pre-granted operations. NostrOpDecision.ASK -> { if (isSessionAllowed(coordinate, op)) { true + } else if (narrowOp != null && isNarrowAllowed(coordinate, narrowOp)) { + true } else { askOpConsent(coordinate, clientPubKey, op, request) } @@ -215,11 +230,23 @@ class Nip46PermissionAuthorizer( val grant = opConsent?.invoke(coordinate, clientPubKey, op, request) ?: return false ledger.record(coordinate, grant) if (grant is SignerOpGrant.AllowForSession) { - throttleLock.withLock { sessionAllows.add(sessionKey(coordinate, op)) } + // Use the GRANT's op, not the requested one: the dialog may have returned a narrower op + // ("only from this counterparty"), and a session grant must be no wider than what was given. + throttleLock.withLock { sessionAllows.add(sessionKey(coordinate, grant.op)) } } return grant.isAllowed } + /** True when a standing or session grant exists for the narrower [narrowOp] (e.g. decrypt-from-X). */ + private suspend fun isNarrowAllowed( + coordinate: String, + narrowOp: NostrSignerOp, + ): Boolean = + isSessionAllowed(coordinate, narrowOp) || + // Only an explicit per-op override counts. decide() would otherwise fall through to the + // app's policy, and FULL_TRUST/REASONABLE would answer for an op nobody ever granted. + ledger.store.loadOpDecision(coordinate, narrowOp)?.let { ledger.decide(coordinate, narrowOp) == NostrOpDecision.ALLOW } ?: false + private suspend fun isSessionAllowed( coordinate: String, op: NostrSignerOp, @@ -344,5 +371,28 @@ class Nip46PermissionAuthorizer( is BunkerRequestNip44Decrypt -> NostrSignerOp.Decrypt else -> null } + + /** + * The NARROWER op a request could be granted, or `null` when it has no narrower form. + * + * Only decryption has one today: `decrypt` reveals private conversations, and one broad + * "always allow" hands over every conversation forever — so the consent dialog can also offer + * "always allow for THIS counterparty" ([NostrSignerOp.DecryptFrom]). Encryption and signing + * have no equivalent: their counterparty/kind is already the thing being granted. + */ + fun BunkerRequest.toNarrowSignerOp(): NostrSignerOp? = + when (this) { + is BunkerRequestNip04Decrypt -> NostrSignerOp.DecryptFrom(pubKey) + is BunkerRequestNip44Decrypt -> NostrSignerOp.DecryptFrom(pubKey) + else -> null + } + + /** The counterparty a decrypt request names, or `null` for any other request. */ + fun BunkerRequest.decryptCounterparty(): HexKey? = + when (this) { + is BunkerRequestNip04Decrypt -> pubKey + is BunkerRequestNip44Decrypt -> pubKey + else -> null + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt index e614b4f51b..4850c173c8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.connectedApps.signers +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest @@ -47,7 +48,15 @@ sealed interface AppConnectResult { * [NostrSignerPermissionLedger] and the bulk capability grant in [NappletBroker][com.vitorpamplona.amethyst.commons.napplet.NappletBroker]. */ fun interface NostrConnectPrompt { - suspend fun request(identity: NappletIdentity): AppConnectResult + /** + * [declared] is the capability set the connection would pre-grant. It has to reach the dialog: + * accepting bulk-grants every one of them as ALLOW_ALWAYS, so a dialog that showed only the + * app's title and icon would be asking for consent to something it never disclosed. + */ + suspend fun request( + identity: NappletIdentity, + declared: Set, + ): AppConnectResult } // --------------------------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt index 5987dc46ef..2187b69f07 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt @@ -33,9 +33,22 @@ sealed interface NostrSignerOp { /** Encrypt a message (NIP-04 or NIP-44). */ data object Encrypt : NostrSignerOp - /** Decrypt a message (NIP-04 or NIP-44). */ + /** Decrypt a message (NIP-04 or NIP-44) from ANYONE — the broad grant. */ data object Decrypt : NostrSignerOp + /** + * Decrypt messages from one [counterparty] only — the narrow alternative to [Decrypt]. + * + * A single "always allow decrypt" grant lets an app read every private conversation the user has, + * forever. Replacing [Decrypt] with this would be worse, though: a DM client would prompt once per + * conversation, training users to approve everything. So this exists *alongside* [Decrypt]: the + * consent dialog offers both, and the authorizer honours a narrow grant when the broad one is + * still ASK. Never auto-granted — only a deliberate "always allow for X" writes one. + */ + data class DecryptFrom( + val counterparty: String, + ) : NostrSignerOp + /** Stable storage key for this operation, used as a DataStore key fragment. */ val key: String get() = @@ -43,6 +56,7 @@ sealed interface NostrSignerOp { is SignKind -> "sign:$kind" Encrypt -> "encrypt" Decrypt -> "decrypt" + is DecryptFrom -> "decrypt:$counterparty" } companion object { @@ -50,6 +64,8 @@ sealed interface NostrSignerOp { when { key == "encrypt" -> Encrypt key == "decrypt" -> Decrypt + // Additive: the broad grant keeps its bare "decrypt" key, so no stored key migrates. + key.startsWith("decrypt:") -> key.removePrefix("decrypt:").ifBlank { null }?.let { DecryptFrom(it) } key.startsWith("sign:") -> key.removePrefix("sign:").toIntOrNull()?.let { SignKind(it) } else -> null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt index ae8d1cd9d4..1c7807a640 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -169,7 +168,9 @@ class NostrSignerPermissionLedger( is NostrSignerOp.SignKind -> if (op.kind in REASONABLE_SIGN_KINDS) NostrOpDecision.ALLOW else NostrOpDecision.ASK NostrSignerOp.Encrypt -> NostrOpDecision.ALLOW - NostrSignerOp.Decrypt -> NostrOpDecision.ASK + // Decryption always asks under REASONABLE — both the broad grant and the per-counterparty + // one, which is only ever created by an explicit "always allow for X" in the dialog. + NostrSignerOp.Decrypt, is NostrSignerOp.DecryptFrom -> NostrOpDecision.ASK } companion object { @@ -187,9 +188,15 @@ class NostrSignerPermissionLedger( * - **zap request** (9734) — moves nothing; it only fetches a Lightning invoice. The payment * itself is the separately-gated `value.payInvoice` capability that prompts on *every* use * regardless of policy. - * - **relay auth** (22242, NIP-42) — an ephemeral proof-of-key bound to a single relay and - * challenge (it cannot be replayed to another relay). Amethyst's own client auto-signs it - * for every logged-in account, so treating it as background noise matches existing behavior. + * + * **Relay auth (22242) is deliberately NOT here.** It looks harmless — the event is ephemeral + * and bound to one relay+challenge, so it cannot be replayed elsewhere. But replay is not the + * threat: the requesting app supplies the `relay` and `challenge` tags verbatim, so it can ask + * for a *fresh* signature naming any relay it likes, then AUTH to that relay as the user. That + * yields read access to whatever the relay gates behind AUTH — notably the user's kind-1059 + * giftwrap inbox and its full DM metadata — and burns the quota on paid relays, which bill + * whoever authenticates. Amethyst auto-signing AUTH for relays *the user configured* is not + * the same as letting a third party name the relay. * * None of the members can silently: spend money, overwrite account configuration (profile 0, * contacts 3, relay/mute/bookmark lists are replaceable — a bad write can wipe settings), @@ -232,7 +239,6 @@ class NostrSignerPermissionLedger( TorrentCommentEvent.KIND, // 2004 — NIP-35 torrent comments HighlightEvent.KIND, // 9802 — highlighted snippets shared publicly LnZapRequestEvent.KIND, // 9734 — Lightning zap request; the payment itself still prompts - RelayAuthEvent.KIND, // 22242 — NIP-42 relay auth; ephemeral, bound to one relay+challenge LongTextNoteEvent.KIND, // 30023 — NIP-23 long-form articles (addressable content) StatusEvent.KIND, // 30315 — ephemeral user status / presence WikiNoteEvent.KIND, // 30818 — NIP-54 wiki articles (addressable content) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index 991be61e4a..d9787fd2cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -27,7 +27,9 @@ import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.UserNip05Cac import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCardsCache +import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata @@ -40,6 +42,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Hex +import kotlin.concurrent.Volatile interface UserDependencies @@ -76,12 +79,27 @@ class User( // These objects are designed to keep the cache // while this user obj is being used anywhere. - private var metadata: UserMetadataCache? = null - private var reports: UserReportCache? = null - private var cards: UserCardsCache? = null - private var nip05: UserNip05Cache? = null - private var status: UserStatusCache? = null - private var relays: UserRelaysCache? = null + // + // `@Volatile` + double-checked locking (see [lazyCacheLock]): these are created on demand + // from BOTH the Compose main thread — every `observeUserInfo`/`observeUserName` composition + // calls the accessor — and the relay/IO threads consuming events (`updateUserInfo` -> + // `metadata()`). The plain `field ?: Create().also { field = it }` idiom is not atomic: two + // threads can both read null, each build a cache, and whoever writes first gets orphaned. + // A UI flow collected from an orphaned UserMetadataCache never receives the metadata, so + // that composable is stuck on its `pubkeyDisplayHex()` fallback forever — while a sibling + // composable that read the surviving instance renders the real name. That is exactly the + // "group DM title shows npubs while the facepile beside it shows names" symptom. + @Volatile private var metadata: UserMetadataCache? = null + + @Volatile private var reports: UserReportCache? = null + + @Volatile private var cards: UserCardsCache? = null + + @Volatile private var nip05: UserNip05Cache? = null + + @Volatile private var status: UserStatusCache? = null + + @Volatile private var relays: UserRelaysCache? = null fun pubkey() = Hex.decode(pubkeyHex) @@ -169,34 +187,48 @@ class User( fun reportsOrNull(): UserReportCache? = reports - fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it } + fun reports(): UserReportCache = reports ?: lazyCacheLock.withLock { reports ?: UserReportCache().also { reports = it } } fun cardsOrNull(): UserCardsCache? = cards - fun cards(): UserCardsCache = cards ?: UserCardsCache().also { cards = it } + fun cards(): UserCardsCache = cards ?: lazyCacheLock.withLock { cards ?: UserCardsCache().also { cards = it } } fun metadataOrNull(): UserMetadataCache? = metadata - fun metadata(): UserMetadataCache = metadata ?: UserMetadataCache().also { metadata = it } + fun metadata(): UserMetadataCache = metadata ?: lazyCacheLock.withLock { metadata ?: UserMetadataCache().also { metadata = it } } fun nip05StateOrNull(): UserNip05Cache? = nip05 fun nip05State(): UserNip05Cache = - nip05 ?: UserNip05Cache().also { - nip05 = it - val meta = metadata().flow.value - if (meta != null) { - it.newMetadata(meta.info.nip05, pubkeyHex) + nip05 ?: lazyCacheLock.withLock { + nip05 ?: UserNip05Cache().also { + nip05 = it + val meta = metadata().flow.value + if (meta != null) { + it.newMetadata(meta.info.nip05, pubkeyHex) + } } } fun statusStateOrNull(): UserStatusCache? = status - fun statusState(): UserStatusCache = status ?: UserStatusCache().also { status = it } + fun statusState(): UserStatusCache = status ?: lazyCacheLock.withLock { status ?: UserStatusCache().also { status = it } } fun relayStateOrNull(): UserRelaysCache? = relays - fun relayState(): UserRelaysCache = relays ?: UserRelaysCache().also { relays = it } + fun relayState(): UserRelaysCache = relays ?: lazyCacheLock.withLock { relays ?: UserRelaysCache().also { relays = it } } + + companion object { + /** + * Guards the double-checked lazy creation of every per-user cache above. + * + * Shared process-wide on purpose: it is only ever held for the few instructions that + * allocate a cache object, and one lock costs far less than one per [User] in a store + * that holds tens of thousands of them. [KmpLock] is reentrant, so `nip05State()` + * calling `metadata()` under the lock cannot deadlock. + */ + private val lazyCacheLock = KmpLock() + } } fun Set.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt index 830bd0590e..f503e34687 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDocument import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -73,12 +74,18 @@ class ConcordChannelListState( fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent - /** Decrypts the current list (or the offline backup) into its entries. */ - suspend fun entriesWithBackup(note: Note): List { + /** + * Decrypts the current list (or the offline backup) into the full document — entries plus + * the residue (unknown keys, tombstones) a read-modify-write has to hand back untouched. + */ + suspend fun documentWithBackup(note: Note): ConcordCommunityListDocument { val event = note.event as? ConcordCommunityListEvent ?: settings.concordList() - return event?.decrypt(signer) ?: emptyList() + return event?.decryptDocument(signer) ?: ConcordCommunityListDocument(emptyList()) } + /** Decrypts the current list (or the offline backup) into its entries. */ + suspend fun entriesWithBackup(note: Note): List = documentWithBackup(note).entries + @OptIn(ExperimentalCoroutinesApi::class) val liveCommunities: StateFlow> = getConcordListFlow() @@ -106,17 +113,17 @@ class ConcordChannelListState( // Seed from the offline backup as well as the live cache event: the saved list is // consumed into the cache asynchronously in `init`, so a join that races that load // would otherwise start from an empty `current` and wipe every prior membership. - val current = entriesWithBackup(concordListNote) - val next = current.filterNot { it.id == entry.id } + entry - return ConcordCommunityListEvent.create(signer, next) + val doc = documentWithBackup(concordListNote) + val next = doc.entries.filterNot { it.id == entry.id } + entry + return ConcordCommunityListEvent.create(signer, next, residue = doc.residue) } /** Drop the community with [communityId] and return the new list event, or null if none existed. */ suspend fun unfollow(communityId: String): ConcordCommunityListEvent? { - val current = entriesWithBackup(concordListNote) - if (current.none { it.id == communityId }) return null - val next = current.filterNot { it.id == communityId } - return ConcordCommunityListEvent.create(signer, next) + val doc = documentWithBackup(concordListNote) + if (doc.entries.none { it.id == communityId }) return null + val next = doc.entries.filterNot { it.id == communityId } + return ConcordCommunityListEvent.create(signer, next, residue = doc.residue) } init { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 46f8aa8573..9bb65aef67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event @@ -113,10 +115,40 @@ class ConcordCommunitySession( /** The next-epoch base-rekey stream address to watch for an inbound Refounding. */ val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex + /** + * The Control Plane of every **prior** epoch we still hold a root for (address -> + * key + epoch), newest-held first and bounded like the channel backfill. + * + * This is the anti-rollback memory. A CORD-06 Refounding re-wraps one edition per + * entity at the new epoch and the *rotator* chooses which one, so it can serve v1 + * of a chain that had already reached v2 — restoring a revoked role, clearing a + * banlist — with every signature genuine. Folding the epochs we still hold roots + * for gives us the [EntityFloor] each entity must connect to, and because + * `heldRoots` is already persisted in the kind-13302 community list, that memory + * survives a process restart without any new storage. + */ + private val historicalControlKeys: Map> = + entry.heldRoots + .filter { it.epoch < entry.rootEpoch } + .sortedByDescending { it.epoch } + .take(ConcordActions.MAX_BACKFILL_EPOCHS) + .mapNotNull { held -> + val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityIdBytes, held.epoch) }.getOrNull() ?: return@mapNotNull null + key.publicKeyHex to (key to held.epoch) + }.toMap() + + /** The prior-epoch Control Plane addresses to subscribe to, so the rollback floor can be rebuilt. */ + fun historicalControlPlaneAddresses(): Set = historicalControlKeys.keys + private val lock = KmpLock() // Deduped inbound wraps. private val controlWraps = LinkedHashMap() + + // Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these + // never join the live fold, they only produce the anti-rollback floor. + private val historicalControlWraps = HashMap>() + private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() @@ -236,6 +268,9 @@ class ConcordCommunitySession( fun streamKeys(): List = lock.withLock { listOf(controlPlaneKey) + + // Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the + // gated relays must serve their wraps too. + historicalControlKeys.values.map { it.first } + channelKeysByAddress.values.map { it.second } + // Prior-epoch channel stream keys so the gated relays serve their older wraps too. historicalChannelKeysByAddress.values.map { it.second } @@ -297,6 +332,17 @@ class ConcordCommunitySession( return ConcordIngestOutcome.STRUCTURAL } else -> { + // A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback + // floor rises as the old epochs drain in. Structural — the floor can change the + // folded state (and therefore the plane set) exactly like a live control wrap. + if (wrap.pubKey in historicalControlKeys) { + lock.withLock { + val buffer = historicalControlWraps.getOrPut(wrap.pubKey) { LinkedHashMap() } + if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup + } + refold() + return ConcordIngestOutcome.STRUCTURAL + } val current = lock.withLock { channelKeysByAddress[wrap.pubKey] } if (current != null) { val (channelIdHex, key) = current @@ -374,7 +420,12 @@ class ConcordCommunitySession( val newChannels = lock.withLock { val wraps = controlWraps.values.toList() - val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) + val folded = + ConcordCommunityState.fold( + ConcordActions.controlEditions(wraps, controlPlaneKey), + entry.owner, + controlFloorsLocked(), + ) val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first } val next = HashMap>() @@ -404,6 +455,30 @@ class ConcordCommunitySession( for (channelIdHex in newChannels) reprojectChannel(channelIdHex) } + /** + * The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's + * Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is + * itself anchored at the one before it and the floor only ever rises. + * + * The current epoch must then connect to these heads; an entity whose offered chain cannot + * reach its floor keeps the state we last folded (see [EditionFold.admissible]) and the + * refusal is warned. Empty for a fresh joiner (no held roots), which is exactly right — it + * legitimately has no history and must still accept the compacted head as its baseline. + * + * Caller must hold [lock]; a fold reads the wrap buffers. + */ + private fun controlFloorsLocked(): Map { + if (historicalControlKeys.isEmpty()) return emptyMap() + var floors = emptyMap() + for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) { + val wraps = historicalControlWraps[address]?.values?.toList() ?: continue + val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first) + if (editions.isEmpty()) continue + floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors) + } + return floors + } + private fun refoldGuestbook() { lock.withLock { val wraps = guestbookWraps.values.toList() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 02adde62fe..cb366ba6fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -93,6 +93,9 @@ class ConcordSessionRegistry( val out = HashSet() for (session in sessions.values) { out += session.controlPlaneAddress + // Prior-epoch Control Planes too: folding them is what gives each entity its + // anti-rollback floor across a CORD-06 Refounding. + out += session.historicalControlPlaneAddresses() out += session.channelAddresses() } out diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt new file mode 100644 index 0000000000..bfb10f4f1b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt @@ -0,0 +1,105 @@ +/* + * 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.model.privateChats + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * True when this event's `content` field holds ciphertext instead of readable text. + * + * These are exactly the kinds whose plaintext has to come out of a decryption cache; their + * raw `content` is a base64 blob (NIP-04 `?iv=` or a NIP-44 payload) and must never + * reach the screen. Anything else — including a NIP-17 rumor, whose content is already + * plaintext by the time it lands in the cache — is safe to render verbatim. + * + * Deliberately narrower than [Event.isContentEncoded], which also covers merely + * *machine-readable* content (kind:0 JSON, OTS blobs, marketplace payloads) that callers + * legitimately read raw. + */ +fun Event.hasEncryptedContent(): Boolean = + when (this) { + is PrivateDmEvent -> true + is DraftWrapEvent -> true + is SealedRumorEvent -> true + is GiftWrapEvent -> true + is LnZapRequestEvent -> isPrivateZap() + else -> false + } + +/** + * What a chat row should render for a message, once the raw ciphertext is off the table. + * + * [Decrypting] and [Undecryptable] are kept apart on purpose: a message that is merely + * still being decrypted resolves on its own and must not be labelled undecryptable. + */ +sealed interface ChatPreview { + /** Readable text, ready to render. */ + data class Body( + val text: String, + ) : ChatPreview + + /** Encrypted, decryptable by this account, plaintext not available yet. */ + data object Decrypting : ChatPreview + + /** Encrypted and this account can never read it (no key, or not a party to the DM). */ + data object Undecryptable : ChatPreview + + /** No event at all — the row is referencing something we never received. */ + data object Missing : ChatPreview +} + +/** + * Pure classifier for the preview text of a chat message. + * + * @param event the message, or null when the note carries no event yet. + * @param decrypted plaintext already available from the decryption cache, if any. + * @param myPubKey the logged-in account's pubkey; null when unknown. + * @param canDecrypt whether the account holds (or can reach) a key at all — i.e. + * `Account.isWriteable()`. A read-only npub login is false. + */ +fun chatPreviewOf( + event: Event?, + decrypted: String?, + myPubKey: HexKey?, + canDecrypt: Boolean, +): ChatPreview { + if (event == null) return ChatPreview.Missing + + if (!event.hasEncryptedContent()) return ChatPreview.Body(decrypted ?: event.content) + + // Never trust `event.content` from here down: it is ciphertext. + if (decrypted != null) return ChatPreview.Body(decrypted) + + if (!canDecrypt) return ChatPreview.Undecryptable + + // A kind:4 addressed to neither me nor from me can't be opened with my key, ever. + if (event is PrivateDmEvent && myPubKey != null && !event.isIncluded(myPubKey)) { + return ChatPreview.Undecryptable + } + + return ChatPreview.Decrypting +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 5c2501011b..48800d865e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -92,7 +92,10 @@ class NappletBroker( private val signerConsentLock = Mutex() // In-memory session grants (AllowForSession): cleared when this broker instance is destroyed. - // Only accessed under signerConsentLock. + // Keyed by "|" via [sessionKey] — this broker instance is shared by every + // applet and browser origin under the account, so an unkeyed op would let one app's session + // grant silently authorize the same op for all the others. Only accessed under + // signerConsentLock. private val sessionAllows = mutableSetOf() // Apps whose first-connect dialog the user just dismissed with Cancel, mapped to the wall-clock @@ -135,7 +138,7 @@ class NappletBroker( } // Show the first-connect dialog if the app has no signer policy yet. - if (signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) { + if (signerLedger != null && !signerLedger.hasPolicy(signerCoordinateFor(identity))) { if (!ensureConnected(identity, declared)) { return NappletResponse.Denied(capability, "Connection not authorized.") } @@ -362,7 +365,7 @@ class NappletBroker( signerConsentLock.withLock { val sl = signerLedger ?: return@withLock true // Re-check after acquiring lock: a sibling request may have set the policy while we waited. - if (sl.hasPolicy(identity.coordinate)) return@withLock true + if (sl.hasPolicy(signerCoordinateFor(identity))) return@withLock true // Within the post-cancel cooldown, suppress re-prompting so a load-time burst doesn't // relaunch the dialog per request. Once it lapses, drop the stale entry and fall through to @@ -374,10 +377,10 @@ class NappletBroker( } val prompt = nostrConnectPrompt ?: return@withLock true - when (val result = prompt.request(identity)) { + when (val result = prompt.request(identity, declared)) { is AppConnectResult.Connected -> { cancelledUntil.remove(identity.coordinate) - sl.setPolicy(identity.coordinate, result.policy) + sl.setPolicy(signerCoordinateFor(identity), result.policy) // Bulk-grant non-payment capabilities only for non-paranoid policies. // PARANOID users chose "ask me for everything" — leave the capability ledger // empty so each capability prompts on first use. @@ -391,7 +394,7 @@ class NappletBroker( true } AppConnectResult.Blocked -> { - sl.setPolicy(identity.coordinate, AppSignerPolicy.PARANOID) + sl.setPolicy(signerCoordinateFor(identity), AppSignerPolicy.PARANOID) for (cap in declared) { ledger.record(identity, cap, GrantState.DENY) } @@ -415,14 +418,15 @@ class NappletBroker( ): Boolean = signerConsentLock.withLock { val sl = signerLedger ?: return@withLock true - // Session grants win immediately without touching storage. - if (op.key in sessionAllows) { - sl.updateLastUsed(identity.coordinate) + // Session grants win immediately without touching storage. Scoped to this applet: a + // grant made for one app never authorizes another. + if (sessionKey(signerCoordinateFor(identity), op) in sessionAllows) { + sl.updateLastUsed(signerCoordinateFor(identity)) return@withLock true } - when (sl.decide(identity.coordinate, op)) { + when (sl.decide(signerCoordinateFor(identity), op)) { NostrOpDecision.ALLOW -> { - sl.updateLastUsed(identity.coordinate) + sl.updateLastUsed(signerCoordinateFor(identity)) true } NostrOpDecision.DENY -> false @@ -430,27 +434,27 @@ class NappletBroker( val prompt = signerConsentPrompt ?: return@withLock true when (val grant = prompt.request(identity, op, request)) { is SignerOpGrant.AllowAll -> { - sl.setPolicy(identity.coordinate, AppSignerPolicy.FULL_TRUST) - sl.updateLastUsed(identity.coordinate) + sl.setPolicy(signerCoordinateFor(identity), AppSignerPolicy.FULL_TRUST) + sl.updateLastUsed(signerCoordinateFor(identity)) true } is SignerOpGrant.AllowForOp -> { - sl.setOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW) - sl.updateLastUsed(identity.coordinate) + sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW) + sl.updateLastUsed(signerCoordinateFor(identity)) true } is SignerOpGrant.AllowForSession -> { - sessionAllows.add(op.key) - sl.updateLastUsed(identity.coordinate) + sessionAllows.add(sessionKey(signerCoordinateFor(identity), op)) + sl.updateLastUsed(signerCoordinateFor(identity)) true } is SignerOpGrant.AllowUntil -> { - sl.setTimedOpDecision(identity.coordinate, op, NostrOpDecision.ALLOW, grant.expiresAt) - sl.updateLastUsed(identity.coordinate) + sl.setTimedOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW, grant.expiresAt) + sl.updateLastUsed(signerCoordinateFor(identity)) true } is SignerOpGrant.DenyForOp -> { - sl.setOpDecision(identity.coordinate, op, NostrOpDecision.DENY) + sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.DENY) false } else -> grant.isAllowed @@ -459,6 +463,58 @@ class NappletBroker( } } + /** + * The signer-ledger coordinate for [identity] under the current account. The signer permission + * store is shared with NIP-46, which already namespaces by account + * (`nip46::` — see Nip46PermissionAuthorizer.coordinateFor); this mirrors that so + * an "always allow" granted by one npub can never authorize signing under another. The bare + * [NappletIdentity.coordinate] stays the app's display/UI identity and is unchanged. + */ + private fun signerCoordinateFor(identity: NappletIdentity): String = signerCoordinateFor(identity.coordinate) + + /** [signerCoordinateFor] for a bare app coordinate — what the Connected Apps UI holds. */ + private fun signerCoordinateFor(coordinate: String): String = "napplet:${signer.pubKey}:$coordinate" + + /** + * Namespaces an in-memory session grant to the applet that was actually prompted for. Mirrors + * `Nip46PermissionAuthorizer.sessionKey`; both authorizers share one process-wide instance, so + * the coordinate is what keeps one app's "allow for this session" from leaking to the rest. + */ + private fun sessionKey( + coordinate: String, + op: NostrSignerOp, + ): String = "$coordinate|${op.key}" + + /** + * Drops every live session grant held for [coordinate], so revoking an app takes effect + * immediately instead of lingering until this broker instance dies. + * + * [coordinate] is the **bare** app coordinate (`:`, or `browser:`) + * — the identity the Connected Apps UI holds. Session keys are stored under the + * account-namespaced [signerCoordinateFor] form, so this namespaces before matching; comparing + * the bare coordinate against those keys would silently match nothing and revoke nothing. + * + * Also clears any post-Cancel re-prompt suppression for the app: after an explicit revoke the + * user's next interaction should prompt, not be dropped by a cooldown from before. + */ + suspend fun revokeSessionGrants(coordinate: String) { + signerConsentLock.withLock { + val prefix = "${signerCoordinateFor(coordinate)}|" + sessionAllows.removeAll { it.startsWith(prefix) } + cancelledUntil.remove(coordinate) + } + } + + /** + * True when [capability] carries a standing denial for [identity]. Push-subscription edge ops + * (identity.watch and friends) short-circuit before [handle], so they have to apply the same + * "a standing denial always wins" rule themselves rather than trusting the declaration alone. + */ + suspend fun isDenied( + identity: NappletIdentity, + capability: NappletCapability, + ): Boolean = ledger.decide(identity, capability) == PermissionDecision.DENY + companion object { /** * How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt index 25c503f4d9..b7e2c4cc3c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt @@ -40,12 +40,22 @@ import com.vitorpamplona.amethyst.commons.util.withLock */ class NappletPermissionLedger( private val store: NappletPermissionStore, + /** + * The account session grants belong to, read at call time. This ledger is a process-wide + * singleton shared by every account, and a bare coordinate carries no account (it is + * `:`), so without this an "allow for this session" granted under one + * npub would authorize the same applet under every other npub on the device. The persistent + * store is namespaced the same way. + */ + private val accountPubKey: () -> String = { "" }, ) { private val lock = KmpLock() - // coordinate -> capability -> ALLOW_SESSION (the only state kept here). + // "\u0000" -> capability -> ALLOW_SESSION (the only state kept here). private val session = mutableMapOf>() + private fun sessionKey(identity: NappletIdentity) = "${accountPubKey()}\u0000${identity.coordinate}" + /** * The standing decision for ([identity], [capability]) without prompting. A persistent * [GrantState.DENY] takes precedence over any session allow. @@ -58,7 +68,7 @@ class NappletPermissionLedger( if (persistent == GrantState.DENY) return PermissionDecision.DENY if (persistent == GrantState.ALLOW_ALWAYS) return PermissionDecision.ALLOW - val sessionGrant = lock.withLock { session[identity.coordinate]?.get(capability) } + val sessionGrant = lock.withLock { session[sessionKey(identity)]?.get(capability) } if (sessionGrant == GrantState.ALLOW_SESSION) return PermissionDecision.ALLOW return PermissionDecision.ASK @@ -77,12 +87,12 @@ class NappletPermissionLedger( when (grant) { GrantState.ALLOW_ALWAYS, GrantState.DENY -> { // A new persistent decision supersedes any lingering session grant. - lock.withLock { session[identity.coordinate]?.remove(capability) } + lock.withLock { session[sessionKey(identity)]?.remove(capability) } store.store(identity.coordinate, capability, grant) } GrantState.ALLOW_SESSION -> lock.withLock { - session.getOrPut(identity.coordinate) { mutableMapOf() }[capability] = grant + session.getOrPut(sessionKey(identity)) { mutableMapOf() }[capability] = grant } GrantState.ALLOW_ONCE, GrantState.ASK -> Unit // transient; not remembered } @@ -96,13 +106,13 @@ class NappletPermissionLedger( identity: NappletIdentity, capability: NappletCapability, ) { - lock.withLock { session[identity.coordinate]?.remove(capability) } + lock.withLock { session[sessionKey(identity)]?.remove(capability) } store.remove(identity.coordinate, capability) } /** Forgets all grants for [identity] — both the persisted and the in-session ones. */ suspend fun revokeAll(identity: NappletIdentity) { - lock.withLock { session.remove(identity.coordinate) } + lock.withLock { session.remove(sessionKey(identity)) } store.clear(identity.coordinate) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index 188f6de563..ec686ec088 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.search import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.currentTimeSeconds @@ -64,7 +65,7 @@ object SearchResultSorter { order: SearchSortOrder, ): List = when (order) { - SearchSortOrder.NAME_AZ -> people.sortedBy { it.toBestDisplayName().lowercase() } + SearchSortOrder.NAME_AZ -> people.sortedBySnapshot { it.toBestDisplayName().lowercase() } SearchSortOrder.NAME_ZA -> people.sortedByDescending { it.toBestDisplayName().lowercase() } else -> people } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SortedBySnapshot.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SortedBySnapshot.kt new file mode 100644 index 0000000000..2cbed2d7c7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SortedBySnapshot.kt @@ -0,0 +1,39 @@ +/* + * 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.util + +/** + * Sorts by a key computed ONCE per element, before any comparison runs. + * + * `sortedBy { it.liveProperty }` re-evaluates the key on every comparison, which is a crash — not a + * cosmetic issue — when the key comes from mutable shared state such as a User's or channel's display + * name. A relay event landing mid-sort changes the key underneath TimSort, it detects the + * inconsistency, and throws `IllegalArgumentException: Comparison method violates its general + * contract!`. It needs no user action and scales with list size: a directory of a thousand groups + * trips it readily while a handful never does. + * + * Snapshotting first makes the comparator total and stable for the duration of the sort, whatever the + * network does. The extra list is the price of not crashing. + */ +inline fun > Iterable.sortedBySnapshot(key: (T) -> R): List = + map { it to key(it) } + .sortedBy { it.second } + .map { it.first } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index 1b91d1c85a..77ed81f1ae 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -24,11 +24,15 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -36,6 +40,7 @@ class ConcordModerationTest { private val owner = NostrSignerInternal(KeyPair()) private val admin = NostrSignerInternal(KeyPair()) private val troll = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) @Test fun ownerDefinesRoleGrantsItAndBans() = @@ -60,13 +65,13 @@ class ConcordModerationTest { position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(), ) - add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L)) + add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey)) // Owner grants that role to the admin user. - add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L)) + add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L, owner = community.ownerPubKey)) // Owner bans the troll. - add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L)) + add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L, owner = community.ownerPubKey)) val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey) @@ -78,20 +83,135 @@ class ConcordModerationTest { assertFalse(state.authority.isBanned(admin.pubKey)) // Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions. - add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L)) + add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey)) val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey) assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty()) // Unbanning the troll clears the flag (version chains onto the ban). - add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L)) + add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L, owner = community.ownerPubKey)) val healed = ConcordCommunityState.fold(editions, community.ownerPubKey) assertFalse(healed.authority.isBanned(troll.pubKey)) // A grant forged by the troll (who outranks nobody) is dropped by the fold. - val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L) + val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L, owner = community.ownerPubKey) val forgedEditions: List = editions + ConcordActions.controlEditions(listOf(forged), cp) val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey) assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN)) } + + /** + * Regression: [ConcordModeration] used to locate an entity's head with + * `current.firstOrNull { … }`. `current` is the raw edition list in **wrap-arrival** + * order, not chain order, so once an entity had ≥2 editions the "head" was whichever + * one a relay delivered first — a stale one. The next edition then chained off it, + * forking the chain at an already-used version, and [EditionFold] resolved the fork by + * `minByOrNull { rumorId }` — a coin flip that could silently drop the change. Bans are + * masked by the down-only healing union, but an unban is not, so an unban simply + * failed to apply. + * + * Here the banlist has two editions (v0 bans [troll], v1 also bans [stranger]) before + * the unban. The unban must chain off v1 — the folded head — at v2, not off v0. + */ + @Test + fun thirdBanlistEditionChainsOffTheFoldedHeadNotTheFirstArrival() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val communityId = community.communityId + + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + // v0: ban the troll. v1: ban the stranger too (chains onto v0). + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp) + + val banlistSoFar = editions.filter { it.entityKind == ControlEntityKind.BANLIST } + assertEquals(2, banlistSoFar.size) + val head = EditionFold.foldEntity(banlistSoFar)!! + assertEquals(1L, head.version) + // The stale v0 sorts first in arrival order — exactly what the old firstOrNull picked up. + assertEquals(0L, banlistSoFar.first().version) + + // Now unban the troll. The head must be found regardless of arrival order — in + // particular in the natural order, where the stale v0 comes first and is exactly + // what the old firstOrNull latched onto. + for (arrival in listOf(editions.toList(), editions.reversed())) { + val unbanWrap = ConcordModeration.unban(owner, cp, communityId, troll.pubKey, arrival, createdAt = 4L, owner = community.ownerPubKey) + val unban = ConcordActions.controlEditions(listOf(unbanWrap), cp).single() + + // Chains onto the folded head (v1), not the first-arrival v0. + assertEquals(2L, unban.version) + assertEquals(head.hashHex, unban.prevHash!!.toHexKey()) + + // And the resulting state is the one the moderator asked for: troll freed, stranger still banned. + val state = ConcordCommunityState.fold(editions + unban, community.ownerPubKey) + assertFalse(state.authority.isBanned(troll.pubKey)) + assertTrue(state.authority.isBanned(stranger.pubKey)) + } + } + + /** The same stale-head trap on a versioned entity: a third role edition must be v2. */ + @Test + fun thirdRoleEditionChainsOffTheFoldedHead() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + val roleId = ByteArray(32) { (it + 1).toByte() } + + fun role(name: String) = RoleEntity(name = name, position = 1, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()) + + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp) + + for (arrival in listOf(editions.toList(), editions.reversed())) { + val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single() + assertEquals(2L, third.version) + + val state = ConcordCommunityState.fold(editions + third, community.ownerPubKey) + assertEquals("Owner", state.roles[roleId.toHexKey()]?.name) + } + } + + /** + * The writer must chain onto the head a READER would honor, not the raw structural tip. + * Given the community's owner, `headOf` folds the authority-gated head, so a rogue + * edition sitting at the tip of the banlist chain does not drag the honest moderator's + * next edition up behind it (version inflation an attacker controls). Armada's writers + * chain off `folded.heads` — `pickHead`'s gated pick — for the same reason. + */ + @Test + fun theWriterChainsOntoTheAuthorityGatedHeadNotTheRogueTip() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val communityId = community.communityId + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + // v0: the owner bans the troll. + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + // v1: the stranger — who holds nothing — forges an empty banlist at the tip. + val rogue = ConcordActions.controlEditions(listOf(ConcordModeration.unban(stranger, cp, communityId, troll.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp).single() + assertEquals(1L, rogue.version) + val poisoned = editions + rogue + + // The owner now bans the stranger. It must chain onto v0 — the head the fold + // honors — not onto the rogue v1, so the version is 1, not 2. + val next = + ConcordActions + .controlEditions( + listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, poisoned, createdAt = 4L, owner = community.ownerPubKey)), + cp, + ).single() + assertEquals(1L, next.version, "the rogue tip must not inflate the honest edition's version") + + // And, critically, the owner's banlist is computed from the GATED head, so it still + // carries the troll. Reading the rogue's content instead would launder the forged + // unban into an owner-signed edition and free the troll for good. + val state = ConcordCommunityState.fold(poisoned + next, community.ownerPubKey) + assertTrue(state.authority.isBanned(troll.pubKey), "the forged unban must not free the troll") + assertTrue(state.authority.isBanned(stranger.pubKey)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt index 96a51803c7..3ddbafa2ba 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -97,6 +97,156 @@ class Nip46PermissionAuthorizerTest { assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) } + // --------------------------------------------------------------------- + // Pairing gate (identity reads) + // --------------------------------------------------------------------- + + @Test + fun isPairedIsFalseBeforeConnectAndTrueAfter() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) + + assertFalse(authorizer.isPaired(client), "a client that never connected must not read the identity") + + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "good")) + + assertTrue(authorizer.isPaired(client), "a successful connect pairs the client") + } + + @Test + fun isPairedStaysFalseAfterARejectedConnect() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) + + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "wrong")) + + assertFalse(authorizer.isPaired(client)) + } + + @Test + fun forgetUnpairsTheClient() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + authorizer.forget(client) + + assertFalse(authorizer.isPaired(client)) + } + + // --------------------------------------------------------------------- + // Per-counterparty decrypt grants + // --------------------------------------------------------------------- + + private val alice = "1".repeat(64) + private val bob = "2".repeat(64) + + @Test + fun aPerCounterpartyDecryptGrantAllowsThatConversationWithoutPrompting() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + var prompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.DenyOnce + }, + ) + + ledger.setOpDecision(coordinate, NostrSignerOp.DecryptFrom(alice), NostrOpDecision.ALLOW) + + assertTrue(authorizer.authorize(client, BunkerRequestNip44Decrypt("1", alice, "ct"))) + assertEquals(0, prompts, "a standing narrow grant must not re-prompt") + } + + @Test + fun aPerCounterpartyDecryptGrantDoesNotLeakToOtherCounterparties() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + ledger.setOpDecision(coordinate, NostrSignerOp.DecryptFrom(alice), NostrOpDecision.ALLOW) + + assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("1", bob, "ct")), "Bob's messages were never granted") + } + + @Test + fun aBroadDecryptDenyIsNotOverriddenByANarrowGrant() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + ledger.setOpDecision(coordinate, NostrSignerOp.Decrypt, NostrOpDecision.DENY) + ledger.setOpDecision(coordinate, NostrSignerOp.DecryptFrom(alice), NostrOpDecision.ALLOW) + + assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("1", alice, "ct")), "an explicit broad DENY is final") + } + + @Test + fun aNarrowRememberFromTheDialogIsPersistedAndReusedForThatCounterpartyOnly() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + var prompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + // The dialog's "Always allow for Alice" button returns the NARROW op. + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowForOp(NostrSignerOp.DecryptFrom(alice)) + }, + ) + + assertTrue(authorizer.authorize(client, BunkerRequestNip44Decrypt("1", alice, "ct"))) + assertEquals(1, prompts) + + // Second request from Alice reuses the stored narrow grant. + assertTrue(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", alice, "ct2"))) + assertEquals(1, prompts, "the narrow grant must be remembered") + + // Bob still prompts (and this prompt would grant Alice again, so it is denied). + authorizer.authorize(client, BunkerRequestNip44Decrypt("3", bob, "ct3")) + assertEquals(2, prompts, "a different counterparty is a different decision") + } + + @Test + fun aNarrowSessionGrantIsScopedToItsOwnCounterparty() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + var prompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowForSession(NostrSignerOp.DecryptFrom(alice)) + }, + ) + + assertTrue(authorizer.authorize(client, BunkerRequestNip44Decrypt("1", alice, "ct"))) + assertTrue(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", alice, "ct2"))) + assertEquals(1, prompts, "the session grant covers Alice for the rest of the session") + + authorizer.authorize(client, BunkerRequestNip44Decrypt("3", bob, "ct3")) + assertEquals(2, prompts, "and must NOT cover Bob") + } + @Test fun paranoidAppRefusesEverythingUntilPerOpGrant() = runTest { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt index f07eb5e833..87b08de6e1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt @@ -51,14 +51,13 @@ class NostrSignerPermissionLedgerTest { ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) // Profile (0), contacts (3), deletion (5), relay list (10002), nutzap (9321), NIP-98 HTTP - // auth (27235), and gift-wrapped DM (1059) must never auto-sign — they change config, - // delete, spend ecash, authorize arbitrary HTTP calls, or leak. These are replaceable - // *configuration* (0/3/10002), unlike addressable *content* such as long-form (30023) which - // is allowed. A nutzap in particular *is* the payment (it carries the ecash proofs), unlike a - // zap request (9734) which only fetches an invoice; and NIP-98 authorizes destructive/admin - // HTTP requests, unlike NIP-42 relay auth (22242) which is a replay-bound read proof. - // Decryption reveals private content, so it also asks. - for (kind in listOf(0, 3, 5, 10002, 9321, 27235, 1059)) { + // auth (27235), relay auth (22242), and gift-wrapped DM (1059) must never auto-sign — they + // change config, delete, spend ecash, authorize arbitrary HTTP calls, authenticate as the + // user, or leak. These are replaceable *configuration* (0/3/10002), unlike addressable + // *content* such as long-form (30023) which is allowed. A nutzap in particular *is* the + // payment (it carries the ecash proofs), unlike a zap request (9734) which only fetches an + // invoice. Decryption reveals private content, so it also asks. + for (kind in listOf(0, 3, 5, 10002, 9321, 27235, 22242, 1059)) { assertEquals( NostrOpDecision.ASK, ledger.decide(coordinate, NostrSignerOp.SignKind(kind)), @@ -72,9 +71,12 @@ class NostrSignerPermissionLedgerTest { assertEquals(NostrOpDecision.ALLOW, ledger.decide(coordinate, NostrSignerOp.SignKind(9734))) assertEquals(NostrOpDecision.ASK, ledger.decide(coordinate, NostrSignerOp.SignKind(9321))) - // Contrast: NIP-42 relay auth (22242) auto-signs — it is a replay-bound read proof — but - // NIP-98 HTTP auth (27235) does not, since it can authorize destructive/admin HTTP calls. - assertEquals(NostrOpDecision.ALLOW, ledger.decide(coordinate, NostrSignerOp.SignKind(22242))) + // NIP-42 relay auth (22242) must ASK. Being replay-bound is not the protection it looks + // like: the requesting app supplies the relay and challenge tags, so it can obtain a FRESH + // signature naming any relay and then AUTH there as the user — reading whatever that relay + // gates behind AUTH (the kind-1059 giftwrap inbox and its DM metadata) and spending the + // quota on paid relays, which bill whoever authenticates. + assertEquals(NostrOpDecision.ASK, ledger.decide(coordinate, NostrSignerOp.SignKind(22242))) assertEquals(NostrOpDecision.ASK, ledger.decide(coordinate, NostrSignerOp.SignKind(27235))) } @@ -104,4 +106,32 @@ class NostrSignerPermissionLedgerTest { val ledger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) assertEquals(NostrOpDecision.ASK, ledger.decide(coordinate, NostrSignerOp.SignKind(1))) } + + // --------------------------------------------------------------------- + // Per-counterparty decrypt op + // --------------------------------------------------------------------- + + @Test + fun decryptFromRoundTripsThroughItsStorageKeyWithoutColliding() { + val alice = "1".repeat(64) + val op = NostrSignerOp.DecryptFrom(alice) + + assertEquals("decrypt:$alice", op.key) + // The broad grant keeps its historical bare key, so no stored decision migrates. + assertEquals("decrypt", NostrSignerOp.Decrypt.key) + assertEquals(op, NostrSignerOp.fromKey(op.key)) + assertEquals(NostrSignerOp.Decrypt, NostrSignerOp.fromKey("decrypt")) + assertEquals(null, NostrSignerOp.fromKey("decrypt:")) + } + + @Test + fun decryptFromAlwaysAsksUnderReasonableSoItIsOnlyEverGrantedExplicitly() = + runTest { + val store = InMemoryNostrSignerPermissionStore() + val ledger = NostrSignerPermissionLedger(store) + val coordinate = "nip46:a:b" + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + + assertEquals(NostrOpDecision.ASK, ledger.decide(coordinate, NostrSignerOp.DecryptFrom("1".repeat(64)))) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt new file mode 100644 index 0000000000..2b4682326f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt @@ -0,0 +1,164 @@ +/* + * 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.model.concord + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +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.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The "leave a Concord community" path: `unfollow` read-modify-writes the private kind-13302 list. + * Everything that makes leaving safe lives here — it must drop only the named community, keep the + * other memberships (and their secrets) intact, and be a pure local list edit that never depends on + * the community's own (possibly dead) relays. + */ +class ConcordChannelListLeaveTest { + private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray())) + + private val alpha = "a".repeat(64) + private val beta = "b".repeat(64) + + private fun entry( + id: String, + name: String, + ) = ConcordCommunityListEntry( + id = id, + owner = signer.pubKey, + ownerSalt = "1".repeat(64), + root = "2".repeat(64), + rootEpoch = 3, + relays = listOf("ws://127.0.0.1:7777"), + name = name, + addedAt = 1000, + ) + + /** Serves the list only from the offline backup — the state a dead-relay community lands in. */ + private class BackupOnlyRepository( + var saved: ConcordCommunityListEvent?, + ) : ConcordListRepository { + override fun concordList() = saved + + override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) { + saved = newConcordList + } + } + + private class StubCache : ICacheProvider { + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = null + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(key: Address): AddressableNote = AddressableNote(key) + + override fun getEventStream(): ICacheEventStream = error("not used") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = null + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + private suspend fun state(vararg entries: ConcordCommunityListEntry) = + ConcordChannelListState( + signer = signer, + cache = StubCache(), + scope = CoroutineScope(Dispatchers.Unconfined), + // The cached note is empty (nothing folded from relays), so every read falls back to the + // offline backup — exactly the situation for a community whose relays no longer answer. + settings = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, entries.toList())), + ) + + @Test + fun leavingDropsOnlyThatCommunity() = + runTest { + val list = state(entry(alpha, "Alpha"), entry(beta, "Beta")) + + val left = list.unfollow(alpha)!! + val remaining = left.decrypt(signer) + + assertEquals(1, remaining.size) + assertEquals(beta, remaining[0].id) + // The surviving membership keeps its secrets — leaving one community must not damage another. + assertEquals("2".repeat(64), remaining[0].root) + assertEquals(3L, remaining[0].rootEpoch) + } + + @Test + fun leavingTheLastCommunityEmptiesTheList() = + runTest { + val list = state(entry(alpha, "Alpha")) + + val left = list.unfollow(alpha)!! + + assertTrue(left.decrypt(signer).isEmpty()) + } + + /** Nothing to publish when we weren't a member: the caller's publish is a no-op on null. */ + @Test + fun leavingSomethingWeNeverJoinedIsANoOp() = + runTest { + val list = state(entry(alpha, "Alpha")) + + assertNull(list.unfollow(beta)) + } + + /** + * The list is only readable by its owner, so the leave write must stay self-encrypted — a leave + * that leaked the remaining memberships in cleartext would be worse than not leaving at all. + */ + @Test + fun theRewrittenListStaysSelfEncrypted() = + runTest { + val list = state(entry(alpha, "Alpha"), entry(beta, "Beta")) + + val left = list.unfollow(alpha)!! + + assertEquals(ConcordCommunityListEvent.KIND, left.kind) + assertTrue(beta !in left.content) + val stranger = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000009".hexToByteArray())) + assertTrue(left.decrypt(stranger).isEmpty()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt new file mode 100644 index 0000000000..978b17130f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -0,0 +1,236 @@ +/* + * 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.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Session-level anti-rollback (CORD-06 §3). + * + * `ConcordRefounding.compactControlPlane` re-wraps ONE edition per entity when a community + * rotates its root, and the ROTATOR picks which one. A rotator that simply omits the newest + * edition of a chain walks the entity backwards — a revoked role restored, a banlist cleared, + * metadata reverted — with every signature genuine. The defense is memory: the account already + * persists the rotated-out roots (`heldRoots`, in the NIP-44 self-encrypted kind-13302 list), so + * the session re-derives each prior epoch's Control Plane, folds it, and requires the new epoch's + * chain to connect to the heads it already knew. + */ +class ConcordRollbackFloorTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun sessionRefusesAMetadataRollbackAcrossARefounding() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + + // Epoch 0: genesis metadata (v0 "Nostrichs") plus an owner rename (v1 "Nostrichs HQ"). + val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane) + val rename = + ConcordModeration.editMetadata( + actor = owner, + controlPlane = community.controlPlane, + communityId = community.communityId, + metadata = MetadataEntity(name = "Nostrichs HQ"), + current = genesisEditions, + createdAt = 2L, + owner = community.ownerPubKey, + ) + val epoch0Wraps = community.genesisWraps + rename + + // The rotator refounds, but compacts from the genesis subset ONLY — the rename (v1) is + // silently dropped. Every wrap it publishes is a genuine, owner-signed edition. + val newRoot = ByteArray(32) { 0x33 } + val newEpoch = community.rootEpoch + 1 + val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) + + // The prior epoch's Control Plane is subscribed and AUTHed for — that is where the floor + // comes from, and without it the client has no memory to check the rotator against. + assertTrue( + session.historicalControlPlaneAddresses().contains(community.controlPlane.publicKeyHex), + "prior-epoch control plane not subscribed", + ) + assertTrue( + session.streamKeys().any { it.publicKeyHex == community.controlPlane.publicKeyHex }, + "prior-epoch control plane not AUTHed", + ) + + // Feed the rolled-back new epoch first, then the prior epoch drains in. + rolledBack.forEach { session.ingest(it) } + epoch0Wraps.forEach { session.ingest(it) } + + assertEquals( + "Nostrichs HQ", + session.state.value + ?.metadata + ?.name, + "the rollback to v0 must be refused", + ) + } + + @Test + fun sessionAdoptsAnHonestCompaction() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane) + val rename = + ConcordModeration.editMetadata( + actor = owner, + controlPlane = community.controlPlane, + communityId = community.communityId, + metadata = MetadataEntity(name = "Nostrichs HQ"), + current = genesisEditions, + createdAt = 2L, + owner = community.ownerPubKey, + ) + val epoch0Wraps = community.genesisWraps + rename + + val newRoot = ByteArray(32) { 0x33 } + val newEpoch = community.rootEpoch + 1 + val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + // Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives. + val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl) + + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) + epoch0Wraps.forEach { session.ingest(it) } + honest.forEach { session.ingest(it) } + + val state = session.state.value + assertEquals("Nostrichs HQ", state?.metadata?.name) + assertTrue(state!!.channels.containsKey(community.generalChannelIdHex), "#general must survive an honest compaction") + } + + /** + * A fresh joiner holds no prior root, so it holds no floor: the dangling compacted head IS its + * baseline (CORD-04 §1). The floor must never regress this — that regression is what hid a + * refounded community's name, icon and channels. + */ + @Test + fun freshJoinerWithNoHeldRootsStillFoldsACompactedPlane() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val newRoot = ByteArray(32) { 0x33 } + val newEpoch = community.rootEpoch + 1 + val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) + assertTrue(session.historicalControlPlaneAddresses().isEmpty()) + compacted.forEach { session.ingest(it) } + + assertEquals( + "Nostrichs", + session.state.value + ?.metadata + ?.name, + ) + assertTrue( + session.state.value!! + .channels + .containsKey(community.generalChannelIdHex), + ) + } + + /** + * The floor is only as trustworthy as the editions it is built from. Any ex-member still holds + * a rotated-out root and could mint a high-version edition on that old Control Plane; if the + * floor were taken from an ungated fold, that would freeze the entity for every honest client. + * [ConcordCommunityState.authorizedHeads] gates the same way the live fold does, so an + * unprivileged author raises no floor. + */ + @Test + fun anUnprivilegedEditionOnAnOldPlaneRaisesNoFloor() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val rogue = NostrSignerInternal(KeyPair()) + val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane) + + // The rogue holds the (rotated-out) root, so it can publish a well-formed v1 metadata + // edition — it just has no MANAGE_METADATA and no owner-rooted grant. + val rogueEdit = + ConcordModeration.editMetadata( + actor = rogue, + controlPlane = community.controlPlane, + communityId = community.communityId, + metadata = MetadataEntity(name = "Hijacked"), + current = genesisEditions, + createdAt = 2L, + owner = community.ownerPubKey, + ) + + val floors = + ConcordCommunityState.authorizedHeads( + ConcordActions.controlEditions(community.genesisWraps + rogueEdit, community.controlPlane), + community.ownerPubKey, + ) + + val metadataFloor = floors[community.communityIdHex] + assertEquals(0L, metadataFloor?.version, "an unauthorized edition must not raise the floor") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt new file mode 100644 index 0000000000..dc6cf2b7a3 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt @@ -0,0 +1,140 @@ +/* + * 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.model.privateChats + +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChatPreviewTest { + private val me = "aa".repeat(32) + private val other = "bb".repeat(32) + private val stranger = "cc".repeat(32) + private val someSig = "dd".repeat(64) + + private val ciphertext = "0tyoSVovKSK9uDKLUVMs137TD0b+vz+fjoJN+gG3Bk4=?iv=6r6yPQ==" + + private fun privateDm( + author: String = other, + recipient: String = me, + ) = PrivateDmEvent( + id = "dm".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = arrayOf(arrayOf("p", recipient)), + content = ciphertext, + sig = someSig, + ) + + private fun nip17Rumor() = + ChatMessageEvent( + id = "cm".padEnd(64, '0'), + pubKey = other, + createdAt = 1_000, + tags = arrayOf(arrayOf("p", me)), + content = "hello there", + sig = someSig, + ) + + // ---- hasEncryptedContent ------------------------------------------------- + + @Test + fun nip04DmCarriesCiphertext() { + assertTrue(privateDm().hasEncryptedContent()) + } + + @Test + fun nip17RumorIsAlreadyPlaintext() { + assertFalse(nip17Rumor().hasEncryptedContent()) + } + + // ---- chatPreviewOf ------------------------------------------------------- + + @Test + fun plaintextMessageRendersItsContent() { + assertEquals( + ChatPreview.Body("hello there"), + chatPreviewOf(nip17Rumor(), decrypted = null, myPubKey = me, canDecrypt = true), + ) + } + + @Test + fun decryptedDmRendersThePlaintext() { + assertEquals( + ChatPreview.Body("hi!"), + chatPreviewOf(privateDm(), decrypted = "hi!", myPubKey = me, canDecrypt = true), + ) + } + + /** The regression under test: the raw base64 blob must never become the preview. */ + @Test + fun pendingDmNeverFallsThroughToCiphertext() { + val preview = chatPreviewOf(privateDm(), decrypted = null, myPubKey = me, canDecrypt = true) + + assertEquals(ChatPreview.Decrypting, preview) + assertFalse(preview is ChatPreview.Body) + } + + @Test + fun readOnlyAccountCannotEverDecrypt() { + assertEquals( + ChatPreview.Undecryptable, + chatPreviewOf(privateDm(), decrypted = null, myPubKey = me, canDecrypt = false), + ) + } + + @Test + fun dmBetweenOtherPeopleIsUndecryptableNotPending() { + assertEquals( + ChatPreview.Undecryptable, + chatPreviewOf( + privateDm(author = other, recipient = stranger), + decrypted = null, + myPubKey = me, + canDecrypt = true, + ), + ) + } + + @Test + fun dmIAuthoredIsStillDecryptableByMe() { + assertEquals( + ChatPreview.Decrypting, + chatPreviewOf( + privateDm(author = me, recipient = other), + decrypted = null, + myPubKey = me, + canDecrypt = true, + ), + ) + } + + @Test + fun missingEventReportsMissing() { + assertEquals( + ChatPreview.Missing, + chatPreviewOf(null, decrypted = null, myPubKey = me, canDecrypt = true), + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 6cc8c16213..0b5bcca09f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -24,7 +24,10 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.InMemoryNappletPermissionStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger @@ -293,13 +296,154 @@ class NappletBrokerTest { var calls = 0 private set - override suspend fun request(identity: NappletIdentity): AppConnectResult { + /** The capabilities the last call was asked to disclose, so tests can assert the dialog got them. */ + var lastDeclared: Set = emptySet() + private set + + override suspend fun request( + identity: NappletIdentity, + declared: Set, + ): AppConnectResult { val answer = answers[minOf(calls, answers.size - 1)] + calls++ + lastDeclared = declared + return answer + } + } + + /** A per-op signer consent prompt that always answers [answer] and counts its calls. */ + private class ScriptedSignerPrompt( + private val answer: SignerOpGrant, + ) : NostrSignerConsentPrompt { + var calls = 0 + private set + + override suspend fun request( + identity: NappletIdentity, + op: NostrSignerOp, + request: NappletRequest, + ): SignerOpGrant { calls++ return answer } } + @Test + fun revokingAnAppDropsItsLiveSessionSignerGrants() = + runTest { + // Regression: "allow for this session" grants live in the broker, keyed by the + // account-namespaced coordinate (`napplet::|`), while the + // Connected Apps UI only ever holds the BARE coordinate. Revoking used to clear the + // persisted ledgers and leave the session grants matching nothing — so a revoked app + // kept signing for as long as any applet surface stayed open. + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + // PARANOID asks for every op, so each publish either prompts or rides a session grant. + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForSession(NostrSignerOp.SignKind(1))) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + relay = RecordingRelay(), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + val publish = NappletRequest.Publish(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm") + + // 1. First publish prompts, and the user allows for the session. + assertIs(broker.handle(applet, publish, allDeclared)) + assertEquals(1, opPrompt.calls) + + // 2. The session grant carries the next publish with no prompt — that's the point of it. + assertIs(broker.handle(applet, publish, allDeclared)) + assertEquals(1, opPrompt.calls) + + // 3. The user revokes the app in Connected Apps, which passes the bare coordinate. + broker.revokeSessionGrants(applet.coordinate) + + // 4. ...so the app has to ask again rather than riding the dead grant. + assertIs(broker.handle(applet, publish, allDeclared)) + assertEquals(2, opPrompt.calls) + } + + @Test + fun revokingOneAppLeavesAnotherAppsSessionGrantsAlone() = + runTest { + // The prefix match must not be so loose that revoking one app disarms every other one + // the user is still using. + val other = NappletIdentity(authorPubKey = "bb".repeat(32), identifier = "other") + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID) + signerLedger.setPolicy("napplet:${signer.pubKey}:${other.coordinate}", AppSignerPolicy.PARANOID) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForSession(NostrSignerOp.SignKind(1))) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + relay = RecordingRelay(), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + val publish = NappletRequest.Publish(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm") + + broker.handle(applet, publish, allDeclared) // applet prompts once + broker.handle(other, publish, allDeclared) // other prompts once + assertEquals(2, opPrompt.calls) + + broker.revokeSessionGrants(applet.coordinate) + + // The other app is untouched and still rides its own session grant. + assertIs(broker.handle(other, publish, allDeclared)) + assertEquals(2, opPrompt.calls) + } + + @Test + fun aGrantFromOneAccountNeverAuthorizesTheSameAppUnderAnother() = + runTest { + // The signer permission store is shared across accounts (and with NIP-46), so the napplet + // coordinate must carry the account. Without that, "always allow" granted by one npub + // silently authorized signing under every other npub on the device — which would defeat + // the point of keeping a pseudonymous account separate from a real one. + val store = InMemoryNostrSignerPermissionStore() + val otherSigner = NostrSignerInternal(KeyPair()) + + fun brokerFor(who: NostrSignerInternal) = + NappletBroker( + signer = who, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = NostrSignerPermissionLedger(store), + nostrConnectPrompt = ScriptedConnectPrompt(AppConnectResult.Connected(AppSignerPolicy.FULL_TRUST)), + ) + + // Account A connects the applet and fully trusts it. + assertEquals( + NappletResponse.PublicKey(signer.pubKey), + brokerFor(signer).handle(applet, NappletRequest.GetPublicKey, allDeclared), + ) + assertEquals(AppSignerPolicy.FULL_TRUST, store.loadPolicy("napplet:${signer.pubKey}:${applet.coordinate}")) + + // Account B has granted the very same applet nothing. + assertNull(store.loadPolicy("napplet:${otherSigner.pubKey}:${applet.coordinate}")) + + // ...so B's broker must run its own first-connect flow rather than inheriting A's trust. + val bConnect = ScriptedConnectPrompt(AppConnectResult.Cancelled) + val bBroker = + NappletBroker( + signer = otherSigner, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = NostrSignerPermissionLedger(store), + nostrConnectPrompt = bConnect, + ) + assertIs(bBroker.handle(applet, NappletRequest.GetPublicKey, allDeclared)) + assertEquals(1, bConnect.calls) + } + @Test fun cancellingFirstConnectThenRetryingRePromptsOnceCooldownLapses() = runTest { @@ -333,7 +477,8 @@ class NappletBrokerTest { clock += 10_000L assertEquals(NappletResponse.PublicKey(signer.pubKey), broker.handle(applet, NappletRequest.GetPublicKey, allDeclared)) assertEquals(2, connect.calls) - assertEquals(AppSignerPolicy.REASONABLE, signerLedger.store.loadPolicy(applet.coordinate)) + // Signer grants are stored under the account-scoped coordinate, not the bare one. + assertEquals(AppSignerPolicy.REASONABLE, signerLedger.store.loadPolicy("napplet:${signer.pubKey}:${applet.coordinate}")) } @Test diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt index 72cc2eb20c..02e35dc06f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt @@ -32,6 +32,46 @@ class NappletPermissionLedgerTest { private fun ledger(store: NappletPermissionStore = InMemoryNappletPermissionStore()) = NappletPermissionLedger(store) + @Test + fun aSessionGrantFromOneAccountDoesNotAuthorizeAnother() = + runTest { + // This ledger is a process-wide singleton shared by every account, and a coordinate carries + // no account, so an unscoped session map let "allow for this session" under one npub + // silently authorize the same applet under every other npub on the device. + var account = "aaaa" + val ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()) { account } + + ledger.record(applet, NappletCapability.RELAY, GrantState.ALLOW_SESSION) + assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.RELAY)) + + account = "bbbb" + assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY)) + + // ...and switching back still honors the grant the user actually made. + account = "aaaa" + assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.RELAY)) + } + + @Test + fun endSessionDropsSessionGrantsAcrossAppsButKeepsPersistedOnes() = + runTest { + // What the broker service calls in onDestroy, once every applet/browser surface has + // unbound. The ledger is now an app-wide singleton that outlives the service, so this is + // what keeps ALLOW_SESSION meaning "this session" rather than "until the process dies". + val store = InMemoryNappletPermissionStore() + val ledger = ledger(store) + ledger.record(applet, NappletCapability.RELAY, GrantState.ALLOW_SESSION) + ledger.record(other, NappletCapability.IDENTITY, GrantState.ALLOW_SESSION) + ledger.record(applet, NappletCapability.STORAGE, GrantState.ALLOW_ALWAYS) + + ledger.endSession() + + assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY)) + assertEquals(PermissionDecision.ASK, ledger.decide(other, NappletCapability.IDENTITY)) + // The user's persisted decision is untouched — ending a session is not a revoke. + assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.STORAGE)) + } + @Test fun unknownGrantDefaultsToAsk() = runTest { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index a84604e7be..530006ad33 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -125,7 +125,9 @@ class CashuWalletOps( private fun ops(mintUrl: String): CashuMintOperations = opsCache.getOrPut(mintUrl.trimEnd('/')) { - CashuMintOperations(MintHttpClient(mintUrl, okHttpClient), secretFactory) + // userConfigured: these are the mints of the user's own NIP-60 wallet, + // added deliberately, so a self-hosted mint on the LAN stays usable. + CashuMintOperations(MintHttpClient(mintUrl, userConfigured = true, okHttpClient = okHttpClient), secretFactory) } /** @@ -923,8 +925,11 @@ class CashuWalletOps( * is typo'd, points at a non-Cashu host, or is otherwise unreachable. * * Throws on failure so the UI can surface the underlying reason. + * + * `userConfigured = true`: the URL was typed by the user into the Add-Mint UI, + * so a self-hosted mint on the LAN is a legitimate target here. */ - suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, okHttpClient).info().name + suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, userConfigured = true, okHttpClient = okHttpClient).info().name /** * Fetch the currently-active keyset id for [mintUrl]. Cheap wrapper diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt index 4a827c4637..2e4c2fe2e5 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt @@ -100,9 +100,18 @@ object NappletRequestRouter { "resource.cancel" -> return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) // identity.watch/unwatch are a push subscription (like relay.subscribe), gated on the - // IDENTITY declaration directly — the actual pubkey stream is the host's job. + // IDENTITY declaration directly — the actual pubkey stream is the host's job. The + // ledger still gets a say: this bypasses NappletBroker.handle, so without an explicit + // check a standing IDENTITY denial would not stop the pushes (and would keep leaking + // account-switch timing and every npub the user rotates between). "identity.watch" -> - return if (NappletCapability.IDENTITY in declared) Outcome.WatchIdentity else Outcome.Ignore + return if (NappletCapability.IDENTITY in declared && + !broker.isDenied(identity, NappletCapability.IDENTITY) + ) { + Outcome.WatchIdentity + } else { + Outcome.Ignore + } "identity.unwatch" -> return Outcome.UnwatchIdentity // inc bus: a topic pub/sub between napplets/services, authorized on the INC declaration diff --git a/docs/changelog/v1.13.00.md b/docs/changelog/v1.13.00.md index 5f38cbd568..d98be693d9 100644 --- a/docs/changelog/v1.13.00.md +++ b/docs/changelog/v1.13.00.md @@ -4,9 +4,10 @@ Highlights: - Adds an **in-app Browser for Nostr web clients**, with support for **Nostr Web Apps (NIP-5D napplets)** and **static websites (NIP-5A nSites)**. They run in a - keyless sandbox that can never touch your private key — every signature, - payment, or data read needs your explicit approval. "Log in with Amethyst" - lets Nostr web apps sign in as you. + keyless sandbox that can never touch your private key. Payments always prompt + with the amount shown; signing and data access are permission-gated, with the + scope you choose when you connect. "Log in with Amethyst" lets Nostr web apps + sign in as you. - Adds **Communities (Concord)**: a new end-to-end-encrypted communities protocol with channels, invites, roles, moderation, and full history — browsable and chattable from Messages. @@ -16,10 +17,11 @@ Highlights: - Adds a **Remote Signer (NIP-46)**: for the first time, other apps and websites can sign through Amethyst — with informed, per-account consent — whether your key lives in Amethyst or on an external signer like Amber. Also adds a - **Privacy Lock** that gates your Messages behind a password or biometric. + **Privacy Lock** on Desktop that gates the Messages and Wallet columns behind + a password. - Adds **relay login permissions (NIP-42)**: when a relay asks you to - authenticate, choose Once / Always / Never per relay, with one-tap trust for - your follows' relays. + authenticate, choose Once / Always / Never per relay, with one-tap rules to + trust relays used by people you follow. - Adds **NIP-29 Groups**: relay-hosted group chat with subgroups, custom roles, invite links, threads, and pinned messages. - Redesigns the **Messages inbox**: groups community and group channels with @@ -34,6 +36,20 @@ Highlights: options that flow through buttons, badges, and highlights. - Adds **proof-of-work (NIP-13)** publishing and **negentropy (NIP-77)** sync. +## Upgrading + +- Web apps, napplets and nSites now get **separate storage per account** — + cookies, logins and site data no longer leak between the accounts on your + device. Existing sites are signed out once as a result; signing back in stores + the session under the account you are using, and switching away and back keeps + it. +- Permissions you granted to web apps are likewise **per account** now, so each + app asks once more under each account you use it with. A permission granted by + one account no longer applies to the others. +- Apps connected through the remote signer (NIP-46) now **ask before signing a + relay login** (NIP-42) instead of approving it automatically under the + "Let's be reasonable" trust level. + ## New Features ### Web Apps, Sites & the Browser @@ -43,8 +59,8 @@ Highlights: cannot reach your private key, storage, or data. - Adds an in-app Browser — a drawer entry and pinnable bottom-bar tab — with an omnibox address bar, autocomplete, history, favorites, recents, and captured - favicons. The trusted app draws the address bar so a page can never spoof its - URL. + favicons. The address bar is drawn by Amethyst, never by the page, so its URL + cannot be spoofed. - Adds "Log in with Amethyst": nSites open with a NIP-07 `window.nostr` provider, so standard Nostr web apps can sign in and request signatures as your active account — consent-gated, sign-only, and scoped per site. @@ -53,7 +69,7 @@ Highlights: badges) — every capability brokered and permission-gated (once / this session / always). - Pins favorite web apps to the bottom bar as embedded, swap-in-place tabs that - stay warm and show the app's own icon. + stay warm between visits. - Discovers web apps: the empty browser suggests a curated list plus nSites and napplets published by people you follow, and profiles gain an "Apps & Sites" tab. @@ -79,7 +95,9 @@ Highlights: - Groups channels by community in Messages with last-message, unread counts, facepiles, and live typing. - Adds moderation: ban/unban with read-time enforcement, role grants, a "Make - admin" toggle, and a full member roster. + admin" toggle, and a full member roster. A ban hides a member's posts for + everyone else; removing their access to the community's keys takes a + Refounding. - Backfills channel history across epochs, paging back to the true start of a channel. - Surfaces Concord replies and reactions on the Notifications tab with a @@ -92,7 +110,8 @@ Highlights: - Reads code with branch/tag switching, file search, image preview, commit history, syntax highlighting, and word-level diff highlighting. - Reviews patches and pull requests with computed diffs and status actions; PR - updates surface on the repo screen. + updates surface on the repo screen. The code browser needs a repository with an + http(s) clone URL. - Manages issues: Issues and Patches & PRs tabs split by open/closed, issue labels with filtering, and a full-screen New Issue composer. - Edits a repository announcement from the repo screen, bookmarks repositories, @@ -104,8 +123,9 @@ Highlights: - Adds Location Channels: geohash-based public rooms that interoperate with BitChat, browsable from a dedicated drawer list and pinnable to the bottom nav. -- Posts under an unlinkable per-area anonymous identity (with an optional - nickname that survives restarts), or opt in to post as your real account. +- Posts under an unlinkable per-area anonymous identity, or opt in to post as + your real account. An optional nickname survives restarts, but it is a single + handle reused in every area — setting one links your posts across areas. - Teleports to any place from a map picker or the feed-filter dialog to read and post to distant rooms, and follows a teleported place to keep its feed. - Reacts, zaps, and replies on location messages, with a "live near you" bubble @@ -126,37 +146,34 @@ Highlights: ### Remote Signer & Security - Lets other apps and websites sign through Amethyst for the first time - (NIP-46): connect by scanning or pasting a `nostrconnect://` code or a - `bunker://` link, and keep signing in the background — whether your key is - stored directly in Amethyst or delegated to an external signer like Amber - (NIP-55). + (NIP-46): connect by scanning or pasting a `nostrconnect://` code, and keep + signing in the background — whether your key is stored directly in Amethyst or + delegated to an external signer like Amber (NIP-55). - Asks for informed consent before authorizing an app — showing its name and icon, which account will sign, the exact permissions, and an event preview — with per-account sheets and one-tap batched approval. - Manages connected apps from a dedicated screen with per-app relay list, live health, time-bound grants, and instant "forget this app". -- Adds a Privacy Lock for Messages (Android and Desktop): gate your DMs behind a - password or biometric, with an inactivity auto-lock and optional preview - redaction. +- Adds a Privacy Lock on Desktop: gate the Messages and Wallet columns behind a + password, with an auto-lock timeout. It is a screen gate, not encryption at + rest — messages continue to sync while it is locked. - Adds interactive relay login prompts (NIP-42): choose Once / Always / Never per relay, with venue-aware prompts for public chats, communities, and live - streams, and one-tap trust for your follows' relays. + streams, and one-tap rules to trust relays used by people you follow. ### Web of Trust (GrapeRank) -- Computes GrapeRank Web-of-Trust scores by crawling the social graph and scoring - every user — now even without a personal account (operator-less mode). -- Crawls followers in reverse (who follows a user) alongside follows-of-follows, - and tracks hop distance and follower count on trust cards. -- Discovers score providers via NIP-85 and fetches follow/profile data through - each author's own outbox relays for fuller coverage. +- Discovers Web-of-Trust score providers via NIP-85 and shows hop distance and + follower count on trust cards. Computing the scores — crawling the social graph, + reverse-follower crawling, and publishing NIP-85 cards — is done by the `amy` + command-line tool, not the app; see the Cli section. ### Publishing & Sync - Adds proof-of-work (NIP-13) publishing: a fire-and-forget mining queue with per-account difficulty and per-category settings, scheduled-post mining, and - per-post overrides, shielded by a foreground service and mined across all - cores. + per-post overrides, shielded by a foreground service and mined across half the + device's cores. - Adds NIP-77 negentropy sync as a first-class capability, with deletion (NIP-09/62) propagation so scoped syncs no longer strand deletions. - Shows PoW mining progress, and surfaces PoW, OpenTimestamps, and location @@ -232,8 +249,8 @@ Highlights: - Adds an HTTP/2 keepalive ping to stop stale-connection image stalls, and trims the image cache and player warm pool under memory pressure. - Hardens the relay client: enforces blocked relays on every REQ/COUNT/publish, - stops re-sending REQs relays refuse, evicts failures on the first strike, and - fixes reconnect backoff on network/transport changes. + backs off from REQs relays repeatedly refuse, drops relays that fail hard + during crawls, and fixes reconnect backoff on network/transport changes. - Fixes nutzap relay routing to receive/advertise on inbox/DM (kind 10019) relays instead of outbox. - Fixes Concord invite handling (revocation, keyless CORD-05, clearer failure @@ -248,8 +265,8 @@ Highlights: main thread. - Serializes account construction so concurrent loaders can't build duplicate accounts. -- Routes Onion-Location through every HTTP client and maps Tor/Arti errors to - accurate SOCKS reply codes. +- Routes Onion-Location through the Android app's HTTP clients and maps Tor/Arti + errors to accurate SOCKS reply codes. - Extracts the rich-text renderer and many event cards (calendar/RSVP, podcast atoms/splits, relay discovery, code snippet, ecash mint, activity, Git diff/status, and more) into shared commons so Desktop and Android render diff --git a/docs/concord-banlist-rank-conformance.md b/docs/concord-banlist-rank-conformance.md new file mode 100644 index 0000000000..afbe98fdbd --- /dev/null +++ b/docs/concord-banlist-rank-conformance.md @@ -0,0 +1,229 @@ +# Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance) + +**Status:** conformance bug. Reproduced in Amethyst and **fixed there** (see §6); present by +inspection in Armada. +**Severity:** privilege escalation. Any `BAN` holder can neutralise every authority above them, +including the owner. +**Reported by:** Amethyst (MIT), 2026-07-20. Findings verified by unit test; see "Evidence" below. + +--- + +## 1. Summary + +CORD-04 §3 requires that, for **every** action, the actor both hold the required permission bit +**and** strictly outrank its target. It names banning as the worked example. But CORD-04 §4 — the +section that actually defines the Banlist — states only the bit half of the rule: + +> The Banlist is the one *anti*-roster: a signed list of npubs, honored only if its signer holds `BAN`. + +Both known implementations implement §4 as written and omit §3's rank half at the Banlist write +path. The result is that **a low-ranked moderator can ban the admins above them, and the owner.** + +This is not a spec gap. §3 is unambiguous and normative. It is a conformance bug that both clients +share, and it is almost certainly caused by §4 being readable in isolation. + +## 2. What the spec requires + +CORD-04 §3 (emphasis in original): + +> `position` orders authority, **lower is higher**: the owner is position 0 (never a Role), a +> roleless member is effectively last, and a member's rank is the lowest position among their Roles. +> One hard rule binds every action: the actor must hold the required bit **and** *strictly* outrank +> its target — equal cannot act on equal (an admin cannot ban a peer admin) — and no edition may +> claim a position at or above its own signer, so nobody can promote themselves toward the top. + +Two things stand out: the rule binds **every action**, and the parenthetical example it chooses is +*exactly a ban* — "an admin cannot ban a peer admin." + +Restated as step 3 of the normative authorisation algorithm, CORD-04 §5: + +> To honor an action, a reader: +> 1. Verifies the seal, learning the actor's real npub. +> 2. Folds the Roster and resolves that npub's effective permissions and position. +> 3. Confirms the actor holds the action's required bit **and** strictly outranks its target, traced +> to the owner. + +And on the owner, CORD-04 §2: + +> The Roster is **owner-rooted**: … the chain terminates at the **owner**, who is proven by the +> `community_id` itself (CORD-02), occupies position 0, and is **supreme and unremovable**. + +The Grant path states the same rule inline, which is presumably why it got implemented there: + +> A **Grant** maps a member's npub to their Roles, honored only if its signer outranks every Role it +> hands out (§3). + +CORD-02 §5 likewise restates it for Kicks: "a Kick honored only if its signer holds `KICK` and +outranks the target, CORD-04". + +**§4 is the only permissioned entity whose section does not restate the rank half.** That asymmetry +looks like the proximate cause of the bug in both codebases. + +## 3. What the implementations do + +### Armada (`src/concord-v2/lib/control.ts`) + +```ts +const banlistGate = (p: ParsedEdition): boolean => { + if (!isAuthorized(roster, p.author, ownerHex, Permissions.BAN)) return false; + if (!citationOk(p)) return false; + try { + return Array.isArray(JSON.parse(p.content)); + } catch { + return false; + } +}; +``` + +with (`src/concord-v2/lib/roles.ts`): + +```ts +export function isAuthorized( + roles: CommunityRoles, actorHex: string, ownerHex: string | undefined, permission: bigint, +): boolean { + if (ownerHex === actorHex) return true; + return hasPermission(roles, actorHex, permission); +} +``` + +`isAuthorized` is a pure bit lookup. Notably Armada **already has** the correct primitive and uses +it on the role path: + +```ts +export function canActOnPosition( + roles: CommunityRoles, actorHex: string, ownerHex: string | undefined, + targetPosition: number, permission: bigint, +): boolean { + if (ownerHex === actorHex) return true; + return hasPermission(roles, actorHex, permission) && + outranks(roles, actorHex, ownerHex, targetPosition); +} +``` + +So the fix is largely a matter of routing the Banlist gate through the primitive that already +exists — though see §5, because a whole-list entity needs a per-entry rule. + +### Amethyst (`quartz/…/concord/cord04Roles/AuthorityResolver.kt`) + +```kotlin +fun banGate(e: ControlEdition): Boolean = + e.author.lowercase() == ownerLower || + effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN) +``` + +and the parallel gate in `ConcordCommunityState.authorizedHeads`: + +```kotlin +EditionFold.foldGated(list, floors) { + authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit)) +} +``` + +Same shape: bit only, no rank. Amethyst also has a correct `canActOn(actor, target, bit)` that +implements §3 faithfully, and uses it for Grants — and, since this report, for deciding what the UI +will *author*. Neither client rank-checks the *contents* of a Banlist edition. + +## 4. Consequences + +All of the following were reproduced against Amethyst's fold with unit tests. Community: owner, +`alice` = Admin at position 1 (holds `BAN`), `bob` = Mod at position 5 (holds `BAN`). + +| # | Scenario | Result | Spec violated | +|---|---|---|---| +| 1 | `bob` (pos 5) bans `alice` (pos 1) | **ban is honored** | §3 "strictly outrank its target" | +| 2 | `bob` bans the **owner** | **ban is honored** | §2 "supreme and unremovable" | +| 3 | A banned member holding `BAN` unbans themselves | **honored** | §4 "drops every event from a banned npub … or authority action" | +| 4 | A forked ban survives an unban that does not chain onto it | **stays banned** | — (interacts with §4 re-heal; see below) | + +**Why #1 is escalation, not a cosmetic ordering issue.** Once banned, a member loses *all* +authority: effective-permission checks are `!isBanned && …` in both clients, and §4 mandates that +honest clients drop every event from a banned npub. So a single edition from the most junior `BAN` +holder permanently silences every admin above them. In a community whose owner is inactive — the +common case CORD-06 Refounding exists to address — there is no one left who can undo it. + +**#2 is the sharpest violation.** The spec goes out of its way to protect position 0, including the +clause "no Role may ever claim position 0, or an owner could create a peer nobody outranks." A +Banlist entry bypasses all of that, because the Banlist is just a list of keys and nothing checks +what is in it. + +**#3 cuts the other way and is worth deciding deliberately.** Because the gate reads role-derived +permissions rather than post-ban ones, a banned moderator can lift their own ban. So bans do not +stick against anyone holding `BAN`. Note this one is a genuine fixpoint-ordering question, not a +plain oversight: you cannot know who is banned until you fold the Banlist, and you cannot decide who +may fold into the Banlist without knowing who is banned. The spec should say which order wins. + +**#4 is a pre-existing interaction, not necessarily a bug.** §4's convergence design ("re-heal … +guarantees convergence to the union") means a ban that forks off the head is unioned in and is not +cleared by an unban that chains elsewhere. That is correct for *legitimate* concurrent bans. It +becomes a weapon once #1 holds, because a rogue's ban is both unauthorised and sticky. + +## 5. Proposed fix + +The difficulty is that the Banlist is a single whole-list document ("replaced entire on every +edit"), while §3's rule is stated per *target*. A gate can only be applied to a whole edition, so +the rule needs a per-entry formulation. We suggest specifying it as a **delta rule**: + +> An edition of the Banlist is evaluated against the state its `prev` denotes. Let *delta* be the +> symmetric difference between the edition's list and that state's list — the npubs it adds and the +> npubs it removes. For each npub in *delta*, the signer MUST hold `BAN` and strictly outrank that +> npub, evaluated against the Roster as of that edition. The owner MUST NOT appear in the Banlist; +> an edition adding them is invalid. Entries in *delta* the signer may not act on are **ignored**; +> the remainder of the edition applies. + +Rationale for each clause: + +- **Delta, not whole list.** Otherwise every edition would have to re-justify every standing ban, + and a moderator could never edit a list containing someone senior — including to add a peon. +- **Removals are gated too.** Unbanning is an authority action on the target; without this, the + rank rule is trivially bypassed (ban is symmetric with unban here). +- **Ignore, don't reject.** Rejecting the whole edition would make one bad entry discard a + legitimate bulk-ban — which §4 explicitly recommends as the collision remedy — and would let a + rogue grief the list by forcing rejections. Ignoring converges and composes with re-heal. +- **"As of that edition."** Ranks change. The spec should state that a Banlist edition's authority + is judged against the Roster the fold has settled behind it, which is what §5's "traced to the + owner" already implies for the Roster and what both clients already do for Grants. +- **Owner excluded explicitly.** Today unbannability is only *derivable* from position 0 plus + strict outranking. Since the Banlist bypasses rank entirely, an explicit prohibition is worth one + sentence. + +We'd also suggest **§4 restating the rank half inline**, the way §2 does for Grants and CORD-02 §5 +does for Kicks. Both independent implementations read §4 in isolation and both got it wrong the same +way; that is strong evidence the section is the problem, not the readers. + +Separately, please rule on **#3**: whether a banned npub's Banlist edition is honored. Our reading of +§4 ("drops every event from a banned npub — message, reaction, edit, or authority action") is that it +must not be, but the fixpoint ordering needs to be stated for that to be implementable consistently. + +## 6. Rollout status + +**Amethyst enforces the rule described in §5 as of this report** — both on the fold +(`AuthorityResolver`) and on what it will author (UI + the ban/unban write path, which now reads the +*honored* banlist so an unauthorized entry can never be laundered into a list we sign). + +We're flagging the consequence plainly: this is consensus-affecting. Until Armada ships the same +rule, the two clients can show different banlists — Amethyst will ignore a ban that Armada honors +whenever the signer did not outrank the target. We judged shipping the spec-conformant behaviour +better than continuing to honor an escalation, but we recognise that is a decision with a cost for +your users as well as ours, and we're happy to discuss timing, or to adjust if you read §3/§5 +differently than we do. + +If it is useful, our implementation is MIT and the delta rule is about 40 lines in +`AuthorityResolver.resolve`; you're welcome to lift the approach outright. + +## 7. Evidence + +Reproductions live in Amethyst's `AuthorityResolverTest` +(`quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/`). Each failed before +the change and passes after it: + +- `aBanHolderCannotBanAMemberItDoesNotOutrank` +- `aBanHolderCannotBanTheOwner` +- `anUnrankedBanIsDroppedWithoutOrphaningTheRestOfTheList` (pins the "ignore, don't reject" + semantics of §5) + +Two companions pin the behaviour the fix had to preserve, and passed throughout: + +- `aBanHolderStillBansThoseItOutranks` +- `theOwnerBansAnyone` + +Happy to port these to Armada's suite or restate them as spec examples if that is useful. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index d7deb5b6f3..8d5cb59f9d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -171,6 +171,9 @@ class NappletBrowserActivity : ComponentActivity() { // Build the WebView from a context forced to the app theme so its content follows DARK/LIGHT even when // the device theme differs (WebView reads the context's theme, not the window's — see nightThemedContext). webView = WebView(nightThemedContext(this, themeType)) + // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its + // profile has otherwise been used), so the storage partition must be chosen before anything else. + NappletWebViewProfile.apply(this, webView, intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)) configureWebView(webView) webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) webView.dropSystemBarInsets() @@ -429,10 +432,32 @@ class NappletBrowserActivity : ComponentActivity() { // Record only a clean http(s) main-frame load — never a typed-but-failed address. if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) { recordHistory(url, view.title) + scheduleFaviconSniff(view, url) } } } + /** + * Second-chance favicon capture for pages `onReceivedIcon` never fires for (SVG-only declarations — + * WebView does not rasterize those into the callback). Deliberately delayed so the WebView's own + * raster path, which usually lands shortly after the page finishes, gets first claim on the host; + * if it did, [lastIconHost] is already set and we skip out entirely. + */ + private fun scheduleFaviconSniff( + view: WebView, + url: String, + ) { + val host = OmniboxInput.hostOf(url) ?: return + view.postDelayed({ + if (mainFrameLoadFailed || host == lastIconHost || view.url != url) return@postDelayed + NappletFaviconSniffer.capture(view) { sniffedHost, bytes -> + if (sniffedHost == lastIconHost) return@capture + lastIconHost = sniffedHost + recordIconBytes(sniffedHost, bytes) + } + }, FAVICON_SNIFF_DELAY_MS) + } + /** Relays a successfully loaded page to the main-process broker for the device-local visit history. */ private fun recordHistory( url: String, @@ -467,6 +492,14 @@ class NappletBrowserActivity : ComponentActivity() { out.toByteArray() } }.getOrNull() ?: return + recordIconBytes(host, bytes) + } + + /** Relays already-encoded icon bytes (PNG/ICO/… or SVG source) to the broker as [host]'s favicon. */ + private fun recordIconBytes( + host: String, + bytes: ByteArray, + ) { val msg = Message.obtain(null, NappletIpc.MSG_RECORD_ICON).apply { data = @@ -751,6 +784,9 @@ class NappletBrowserActivity : ComponentActivity() { /** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */ private const val ICON_MAX_PX = 96 + /** Grace period after page-finish before the declared-icon sniff runs, so `onReceivedIcon` wins first. */ + private const val FAVICON_SNIFF_DELAY_MS = 1_200L + private const val EXTRA_URL = "url" private const val EXTRA_PROXY_PORT = "proxyPort" private const val EXTRA_USE_TOR = "useTor" @@ -766,6 +802,7 @@ class NappletBrowserActivity : ComponentActivity() { title: String = "", theme: String = "SYSTEM", isFavorite: Boolean = false, + webViewProfile: String? = null, ): Intent = Intent() .setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity") @@ -775,6 +812,9 @@ class NappletBrowserActivity : ComponentActivity() { .putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_THEME, theme) .putExtra(EXTRA_IS_FAVORITE, isFavorite) + // Opaque per-account storage partition; shares the host contract's key so there is one + // name for the concept across every WebView creation site. + .putExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE, webViewProfile) // Distinct task identity per URL for documentLaunchMode=intoExisting. .setData(url.toUri()) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index b4f129769d..aa2aa1e05a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -136,6 +136,14 @@ object NappletBrowserContract { */ const val KEY_THEME = "theme" + /** + * Opaque per-account WebView storage-profile name (a truncated SHA-256 of the account pubkey, + * minted in the main process). Partitions cookies/localStorage/IndexedDB/service workers per + * account so an embedded site can't carry one npub's session into another. See + * [NappletHostContract.EXTRA_WEBVIEW_PROFILE]. + */ + const val KEY_WEBVIEW_PROFILE = "webViewProfile" + /** * Opaque per-tab session id the client stamps on [MSG_CREATE_SESSION] and every control message * ([MSG_NAVIGATE]/[MSG_RELOAD]/[MSG_BACK]/[MSG_SET_TOR]). A single provider instance is shared by all diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 9208f9d275..27456bcc10 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -46,6 +46,7 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat @@ -87,6 +88,8 @@ class NappletBrowserService : Service() { var useTor: Boolean, val bgColor: Int, val themeType: String, + // Opaque per-account WebView storage-profile name (see NappletWebViewProfile). + val webViewProfile: String?, ) { var webView: WebView? = null var bridgeReplyProxy: JavaScriptReplyProxy? = null @@ -96,6 +99,11 @@ class NappletBrowserService : Service() { // the surface (the embedded surface has no error page of its own). var loadFailed = false + // Host whose favicon this tab already relayed. A page fires the icon callbacks several times, and + // both capture paths (WebView raster + declared-icon sniff) can land for the same visit, so this + // keeps it to one relay per host per visit. Re-armed when the host changes. + var lastIconHost: String? = null + // Per visited origin: its broker-minted launch token, the requests queued until it arrives, and // the origins a mint is already in flight for — so NIP-07 consent is scoped per site, per tab. val originTokens = mutableMapOf() @@ -159,6 +167,7 @@ class NappletBrowserService : Service() { useTor = data.getBoolean(NappletBrowserContract.KEY_USE_TOR, false), bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, android.graphics.Color.WHITE), themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" }, + webViewProfile = data.getString(NappletBrowserContract.KEY_WEBVIEW_PROFILE), ) tabs[sessionId] = tab // Bind the broker once; a re-sent MSG_CREATE_SESSION (e.g. client reconnect) must not @@ -259,6 +268,10 @@ class NappletBrowserService : Service() { // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId") val wv = WebView(nightThemedContext(context, tab.themeType)) + // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its + // profile has otherwise been used), so the storage partition must be chosen before the + // configure/bridge setup and the loadUrl below. + NappletWebViewProfile.apply(context, wv, tab.webViewProfile) configureWebView(wv, tab) // Theme the pre-load background so a blank/loading page shows Amethyst's background, not white. wv.setBackgroundColor(tab.bgColor) @@ -321,6 +334,22 @@ class NappletBrowserService : Service() { private inner class BrowserChromeClient( private val tab: BrowserTab?, ) : WebChromeClient() { + /** + * The embedded surface captures favicons just like the full-screen browser does — a site pinned + * to a tab but never opened full-screen would otherwise never contribute an icon at all. + */ + override fun onReceivedIcon( + view: WebView, + icon: Bitmap?, + ) { + val tab = tab ?: return + if (icon == null || tab.loadFailed) return + val host = OmniboxInput.hostOf(view.url ?: return) ?: return + if (host == tab.lastIconHost) return + tab.lastIconHost = host + recordIcon(host, icon) + } + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { if (tab == null) return false pushConsoleLog( @@ -378,6 +407,8 @@ class NappletBrowserService : Service() { ) { // A new main-frame navigation cleared any prior error. tab?.loadFailed = false + // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. + if (tab != null && OmniboxInput.hostOf(url) != tab.lastIconHost) tab.lastIconHost = null pushUrl(tab, view) pushLoadState(tab, view, isLoading = true) } @@ -394,6 +425,7 @@ class NappletBrowserService : Service() { ) { pushUrl(tab, view) pushLoadState(tab, view, isLoading = false) + if (url.startsWith("https://") || url.startsWith("http://")) scheduleFaviconSniff(tab, view, url) } override fun onReceivedError( @@ -544,6 +576,60 @@ class NappletBrowserService : Service() { if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) } + /** + * Second-chance favicon capture for pages `onReceivedIcon` never fires for (SVG-only declarations — + * WebView does not rasterize those into the callback). Delayed so the WebView's own raster path, + * which usually lands just after page-finish, gets first claim on the host. + */ + private fun scheduleFaviconSniff( + tab: BrowserTab?, + view: WebView, + url: String, + ) { + if (tab == null) return + val host = OmniboxInput.hostOf(url) ?: return + view.postDelayed({ + if (tabs[tab.sessionId] !== tab || tab.loadFailed || host == tab.lastIconHost || view.url != url) return@postDelayed + NappletFaviconSniffer.capture(view) { sniffedHost, bytes -> + if (sniffedHost == tab.lastIconHost) return@capture + tab.lastIconHost = sniffedHost + recordIconBytes(sniffedHost, bytes) + } + }, FAVICON_SNIFF_DELAY_MS) + } + + /** Scales [icon] down and relays it to the broker as the favicon for [host] (PNG bytes over IPC). */ + private fun recordIcon( + host: String, + icon: Bitmap, + ) { + val bytes = + runCatching { + val scaled = if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) icon.scale(ICON_MAX_PX, ICON_MAX_PX) else icon + ByteArrayOutputStream().use { out -> + scaled.compress(Bitmap.CompressFormat.PNG, 100, out) + out.toByteArray() + } + }.getOrNull() ?: return + recordIconBytes(host, bytes) + } + + /** Relays already-encoded icon bytes (PNG/ICO/… or SVG source) to the broker as [host]'s favicon. */ + private fun recordIconBytes( + host: String, + bytes: ByteArray, + ) { + val msg = + Message.obtain(null, NappletIpc.MSG_RECORD_ICON).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_ICON_HOST, host) + putByteArray(NappletIpc.KEY_ICON_BYTES, bytes) + } + } + if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) + } + private fun sendToBroker(msg: Message) { try { brokerMessenger?.send(msg) @@ -596,5 +682,11 @@ class NappletBrowserService : Service() { private companion object { private const val TAG = "NappletBrowserService" private const val ABOUT_BLANK = "about:blank" + + /** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */ + private const val ICON_MAX_PX = 96 + + /** Grace period after page-finish before the declared-icon sniff runs, so `onReceivedIcon` wins first. */ + private const val FAVICON_SNIFF_DELAY_MS = 1_200L } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt new file mode 100644 index 0000000000..ee9b5e333e --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt @@ -0,0 +1,233 @@ +/* + * 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.napplethost + +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.util.Log +import android.webkit.WebView +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import org.json.JSONObject + +/** + * Declared-favicon capture for the sandboxed browser WebViews, complementing + * `WebChromeClient.onReceivedIcon`. + * + * `onReceivedIcon` only ever hands back a **rasterized** `Bitmap`, and Android WebView simply does not + * rasterize an SVG favicon into it. A site that declares *only* `` + * (ditto.pub) therefore never fires the callback at all, while a site that also declares a PNG + * alternative (brainstorm.nosfabrica.com) does. This closes that gap: after the page settles we ask the + * **page itself** for its declared icon and let the **page** fetch the bytes. + * + * Privacy: every byte here is fetched by `fetch()` running *inside the loaded page's own JS context*, so + * the request rides the exact network path the page already rides — the sandbox WebView's proxy, i.e. + * Tor when Tor is on. The main process still never touches an icon URL, which is the property + * `BrowserIconRegistry`'s KDoc protects. Fetches are `credentials: 'omit'` so no cookie rides along. + * + * Trust: the returned bytes come from a page we do not trust, so they are size-bounded and magic-byte + * validated here before they are relayed. Anything unrecognized is dropped silently — a site with no + * usable icon simply has no icon. + */ +internal object NappletFaviconSniffer { + private const val TAG = "NappletFaviconSniffer" + + /** Hard cap on the icon payload. Well under the Binder limit and far above any sane favicon. */ + private const val MAX_ICON_BYTES = 384 * 1024 + + /** Polls for the in-page async fetch to settle. Bounded: gives up (silently) after the last one. */ + private val POLL_DELAYS_MS = longArrayOf(400, 900, 1_800, 3_000, 5_000) + + private val handler = Handler(Looper.getMainLooper()) + + /** + * Asks [webView]'s current page for its best declared icon and relays the bytes to [onIcon]. + * + * No-ops unless the page is a real http(s) page with a parseable host. [onIcon] fires at most once + * per call, on the main thread; it never fires when the page declares nothing usable, when every + * candidate fails to load, or when the page navigates away mid-flight. + */ + fun capture( + webView: WebView, + onIcon: (host: String, bytes: ByteArray) -> Unit, + ) { + val url = webView.url ?: return + if (!url.startsWith("https://") && !url.startsWith("http://")) return + val host = OmniboxInput.hostOf(url) ?: return + + // Scopes this attempt to this navigation: a poll that lands after the page moved on sees a + // different (or absent) seq and drops out instead of attributing a stale icon to the new host. + val seq = System.nanoTime().toString() + runCatching { webView.evaluateJavascript(startScript(seq), null) } + .onFailure { return } + + poll(webView, host, seq, url, 0, onIcon) + } + + private fun poll( + webView: WebView, + host: String, + seq: String, + pageUrl: String, + attempt: Int, + onIcon: (String, ByteArray) -> Unit, + ) { + if (attempt >= POLL_DELAYS_MS.size) return + handler.postDelayed({ + // The page navigated away — this navigation's icon is no longer interesting. + if (webView.url != pageUrl) return@postDelayed + runCatching { + webView.evaluateJavascript(pollScript(seq)) { raw -> + val state = parse(raw) + when { + state == null -> poll(webView, host, seq, pageUrl, attempt + 1, onIcon) + state.first == "ok" -> decode(state.second)?.let { onIcon(host, it) } + state.first == "pending" -> poll(webView, host, seq, pageUrl, attempt + 1, onIcon) + else -> Unit // "none" — the page has no icon we could load. Leave it undecorated. + } + } + } + }, POLL_DELAYS_MS[attempt]) + } + + /** `state` to base64 payload, or null when the page has not produced a result for this seq yet. */ + private fun parse(raw: String?): Pair? { + if (raw == null || raw == "null") return null + val json = runCatching { JSONObject(raw) }.getOrNull() ?: return null + val state = json.optString("state").ifBlank { return null } + return state to json.optString("data") + } + + /** + * Base64 → validated image bytes. Rejects anything that is not a recognizable image, and trims any + * leading whitespace/BOM off an SVG so Coil's content sniffing (which requires `<` at offset 0 to + * recognize an SVG) still fires even though the registry stores every icon under a `.png` name. + */ + private fun decode(base64: String): ByteArray? { + if (base64.isBlank()) return null + // 4 base64 chars per 3 bytes; reject before allocating the decoded array. + if (base64.length > (MAX_ICON_BYTES / 3) * 4 + 4) return null + val bytes = + runCatching { Base64.decode(base64, Base64.DEFAULT) } + .onFailure { Log.w(TAG, "Undecodable favicon payload", it) } + .getOrNull() ?: return null + if (bytes.isEmpty() || bytes.size > MAX_ICON_BYTES) return null + if (isRaster(bytes)) return bytes + return trimmedSvg(bytes) + } + + private fun isRaster(b: ByteArray): Boolean { + if (b.size < 4) return false + + fun at(i: Int) = b[i].toInt() and 0xFF + // PNG, JPEG, GIF, BMP, ICO/CUR, RIFF (WebP). + if (at(0) == 0x89 && at(1) == 0x50 && at(2) == 0x4E && at(3) == 0x47) return true + if (at(0) == 0xFF && at(1) == 0xD8 && at(2) == 0xFF) return true + if (at(0) == 0x47 && at(1) == 0x49 && at(2) == 0x46 && at(3) == 0x38) return true + if (at(0) == 0x42 && at(1) == 0x4D) return true + if (at(0) == 0x00 && at(1) == 0x00 && (at(2) == 0x01 || at(2) == 0x02) && at(3) == 0x00) return true + if (at(0) == 0x52 && at(1) == 0x49 && at(2) == 0x46 && at(3) == 0x46) return true + return false + } + + /** Non-null only when the payload really is XML/SVG text, re-based so byte 0 is the opening `<`. */ + private fun trimmedSvg(b: ByteArray): ByteArray? { + var start = 0 + // UTF-8 BOM. + if (b.size >= 3 && (b[0].toInt() and 0xFF) == 0xEF && (b[1].toInt() and 0xFF) == 0xBB && (b[2].toInt() and 0xFF) == 0xBF) start = 3 + while (start < b.size && b[start].toInt().toChar().isWhitespace()) start++ + if (start >= b.size || b[start].toInt().toChar() != '<') return null + // Must actually mention an = 0; + var isSvg = type.indexOf('svg') >= 0 || /\.svg([?#]|${'$'})/i.test(href); + if (isIcon && !isSvg) cands.push({ h: href, s: 100 }); + else if (isIcon && isSvg) cands.push({ h: href, s: 80 }); + else if (isApple) cands.push({ h: href, s: 60 }); + } + if (location.origin && location.origin.indexOf('http') === 0) { + cands.push({ h: location.origin + '/favicon.ico', s: 10 }); + } + cands.sort(function (a, b) { return b.s - a.s; }); + if (!cands.length) { st.state = 'none'; return; } + (function next(i) { + if (i >= cands.length) { st.state = 'none'; return; } + fetch(cands[i].h, { credentials: 'omit', redirect: 'follow' }) + .then(function (r) { if (!r.ok) throw 0; return r.blob(); }) + .then(function (blob) { + if (!blob || !blob.size || blob.size > $MAX_ICON_BYTES) throw 0; + return new Promise(function (res, rej) { + var fr = new FileReader(); + fr.onload = function () { res(String(fr.result)); }; + fr.onerror = function () { rej(0); }; + fr.readAsDataURL(blob); + }); + }) + .then(function (durl) { + var c = durl.indexOf(','); + if (c < 0) throw 0; + st.data = durl.substring(c + 1); + st.state = 'ok'; + }) + .catch(function () { next(i + 1); }); + })(0); + } catch (e) { + window['__amethystFavicon'] = { seq: '$seq', state: 'none', data: '' }; + } + })(); + """.trimIndent() + + /** Returns the stashed result as an object (WebView JSON-encodes it), or null if it is not ours. */ + private fun pollScript(seq: String): String = + """ + (function () { + var s = window['__amethystFavicon']; + if (!s || s.seq !== '$seq') return null; + return { state: s.state, data: s.data || '' }; + })(); + """.trimIndent() +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index 26922f5217..ef27bed471 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -127,6 +127,10 @@ class NappletHostActivity : ComponentActivity() { private var themeType: String = "SYSTEM" + // Opaque per-account WebView storage-profile name (see NappletWebViewProfile). Null/blank when the + // launcher didn't scope one, which lands on the shared default jar. + private var webViewProfile: String? = null + // Pre-localized capability labels for the "what it can access" sheet (resolved by the launcher). private var capabilityLabels: List = emptyList() @@ -247,6 +251,9 @@ class NappletHostActivity : ComponentActivity() { // Built from a context forced to the app theme so its content follows DARK/LIGHT regardless of the // device theme (WebView reads the context's theme, not the window's — see nightThemedContext). webView = WebView(nightThemedContext(this, themeType)) + // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its + // profile has otherwise been used), so the storage partition must be chosen before anything else. + NappletWebViewProfile.apply(this, webView, webViewProfile) hardenWebView(webView) // Theme the WebView's pre-paint background to the app's so it doesn't flash white when the shell // mounts. This activity has a themed context, so it resolves the color locally (no IPC needed). @@ -463,6 +470,7 @@ class NappletHostActivity : ComponentActivity() { launchToken = intent.getStringExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty() capabilityLabels = intent.getStringArrayListExtra(NappletHostContract.EXTRA_CAP_LABELS) ?: emptyList() themeType = intent.getStringExtra(NappletHostContract.EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" } + webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE) val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() val resolved = resolveRequiredCapabilities(requires) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt index 79aed1a630..2dc7f2b6af 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt @@ -81,6 +81,17 @@ object NappletHostContract { */ const val EXTRA_THEME = "napplet_theme" + /** + * Opaque per-account WebView storage-profile name (a truncated SHA-256 of the account pubkey, + * minted in the main process — see `NappletWebViewProfiles`). Partitions cookies/localStorage/ + * IndexedDB/service workers per account so a web app can't carry one npub's session into another, + * while a switch away and back restores the earlier session. Deliberately a hash: the keyless + * sandbox must learn nothing about which account it runs for. + * + * NB: unrelated to [EXTRA_HOST_PROFILE], which is the WEBSITE/NAPPLET host posture. + */ + const val EXTRA_WEBVIEW_PROFILE = "napplet_webview_profile" + /** * FQN of the main-process broker service (in `:amethyst`). The sandbox binds it by name so it * needs no compile-time reference to `:amethyst`. Must match the manifest `` declaration. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index d038a40ec2..51c8064a3b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -98,6 +98,8 @@ class NappletHostService : Service() { val proxyPort: Int, val bgColor: Int, val themeType: String, + // Opaque per-account WebView storage-profile name (see NappletWebViewProfile). + val webViewProfile: String?, val declaredDomains: List, ) { // Read on the WebView worker thread in shouldInterceptRequest; written on the main thread in @@ -215,6 +217,7 @@ class NappletHostService : Service() { proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1), bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE), themeType = data.getString(NappletHostContract.EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" }, + webViewProfile = data.getString(NappletHostContract.EXTRA_WEBVIEW_PROFILE), declaredDomains = declaredDomains, ) return tab @@ -290,6 +293,10 @@ class NappletHostService : Service() { // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId") val wv = WebView(nightThemedContext(context, tab.themeType)) + // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its + // profile has otherwise been used), so the storage partition must be chosen before the + // hardening/bridge setup and the loadUrl below. + NappletWebViewProfile.apply(context, wv, tab.webViewProfile) val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) val effectiveProxy = if (tab.useTor) tab.proxyPort else -1 tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletWebViewProfile.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletWebViewProfile.kt new file mode 100644 index 0000000000..7b782580b1 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletWebViewProfile.kt @@ -0,0 +1,112 @@ +/* + * 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.napplethost + +import android.content.Context +import android.util.Log +import android.webkit.CookieManager +import android.webkit.WebStorage +import android.webkit.WebView +import androidx.core.content.edit +import androidx.webkit.ProfileStore +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +/** + * Partitions WebView storage (cookies, localStorage, IndexedDB, service workers) per Nostr account. + * + * Without this, every account on the device shares one cookie/storage jar, so a web app stays logged + * in as account A after the user switches to account B — leaking A's session to B and letting a site + * correlate a user's separate pseudonymous npubs. + * + * The name is an OPAQUE token minted by the trusted main process (a truncated SHA-256 of the account + * pubkey — see `NappletWebViewProfiles` in `:amethyst`). This keyless `:napplet` process must never + * learn which account it is running for, so it only ever sees the hash, validates its shape, and uses + * it as a storage key. Same account always maps to the same name, which is what makes a switch away + * and back restore the earlier session intact. + */ +object NappletWebViewProfile { + private const val TAG = "NappletWebViewProfile" + + /** Exactly the shape the main process mints: a 32-char lowercase hex slice of a SHA-256. */ + private val VALID_NAME = Regex("[0-9a-f]{32}") + + /** Storage-key for the fallback path's "which account did this process last serve" marker. */ + private const val FALLBACK_PREFS = "napplet_webview_profile" + private const val FALLBACK_KEY = "last_profile" + + /** Profile name we use when there is no account scope (logged out) or the extra is malformed. */ + private const val NO_ACCOUNT = "default" + + /** + * Points [webView] at [profileName]'s own storage jar. + * + * MUST be called immediately after the WebView is constructed and BEFORE anything touches its + * profile — any `loadUrl`/`loadDataWithBaseURL`, or the settings/bridge calls that follow it in + * our creation paths. `WebViewCompat.setProfile` throws once the WebView has loaded content or + * has been destroyed, so every caller places it as the first statement after the constructor. + */ + fun apply( + context: Context, + webView: WebView, + profileName: String?, + ) { + // Never hand an attacker-influenced string to getOrCreateProfile: anything that isn't the + // exact minted shape is treated as "no account" instead. + val name = profileName?.takeIf(VALID_NAME::matches) + + if (name != null && WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) { + runCatching { + ProfileStore.getInstance().getOrCreateProfile(name) + WebViewCompat.setProfile(webView, name) + }.onFailure { + Log.w(TAG, "Failed to apply the per-account WebView profile; clearing instead", it) + clearIfAccountChanged(context, name) + } + } else { + // FALLBACK (old WebView without MULTI_PROFILE, or no account scope). We must not silently + // share the one jar across accounts, so isolation degrades to "lossy but safe": whenever + // the account behind these WebViews changes, wipe the shared jar instead of partitioning + // it. Sessions don't survive a switch, but they never cross accounts either. + clearIfAccountChanged(context, name ?: NO_ACCOUNT) + } + } + + /** + * Wipes the shared cookie/storage jar when [name] differs from the account this process last + * served. Persisted (not just in-memory) because `:napplet` is killed and respawned constantly — + * an in-memory marker would wipe the user's sessions on every cold start. + */ + private fun clearIfAccountChanged( + context: Context, + name: String, + ) { + val prefs = context.getSharedPreferences(FALLBACK_PREFS, Context.MODE_PRIVATE) + if (prefs.getString(FALLBACK_KEY, null) == name) return + prefs.edit { putString(FALLBACK_KEY, name) } + + runCatching { + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + WebStorage.getInstance().deleteAllData() + }.onFailure { Log.w(TAG, "Failed to clear WebView storage on account change", it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index c59e604dc5..871666677b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -23,23 +23,95 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull -/** A past root key for a specific epoch, kept so historical channel keys stay derivable. */ +/** The "nothing unknown was seen here" extras bag. */ +val NoExtras: JsonObject = JsonObject(emptyMap()) + +/** + * A past root key for a specific epoch, kept so historical channel keys stay derivable. + * + * [extras] carries any key another client wrote inside this held root that we do not + * model, so a read-modify-write does not delete it (see [ConcordCommunityList]). + */ @Serializable class HeldRoot( val epoch: Long, val key: String, + val extras: JsonObject = NoExtras, ) -/** A private channel's delivered key at a given epoch (for private channels the member can read). */ +/** + * A private channel's delivered key at a given epoch (for private channels the member can read). + * + * [extras] carries any unmodelled key another client wrote inside this channel entry. + */ @Serializable class PrivateChannelKey( val channelId: String, val key: String, val epoch: Long, val name: String = "", + val extras: JsonObject = NoExtras, +) + +/** + * Everything about one list entry that we parsed but do not model, kept verbatim so a + * read-modify-write cycle re-emits it byte-for-byte instead of deleting another client's data. + * + * - [entryExtras] — unknown keys at the entry level (siblings of `community_id`/`current`). + * - [seed] — the entry's `seed` object **exactly as it arrived**. `seed` is the immutable + * join anchor (Armada's backfill snapshot); we never hydrate a live entry from it when a + * `current` exists, so we have no business rewriting it either. Re-emitting it verbatim + * preserves every key at every depth inside it for free. Null when the document had none, + * in which case the encoder seeds it from the current join material. + * - [currentExtras] — unknown keys inside the `current` join material. Unknown keys nested + * deeper (inside a channel or a held root) ride on [PrivateChannelKey.extras] and + * [HeldRoot.extras]. + */ +class ConcordEntryResidue( + val entryExtras: JsonObject = NoExtras, + val seed: JsonObject? = null, + val currentExtras: JsonObject = NoExtras, +) { + companion object { + val EMPTY = ConcordEntryResidue() + } +} + +/** + * Everything about the list *document* that we parsed but do not model. + * + * - [extras] — unknown keys at the document root (siblings of `entries`/`tombstones`). + * - [tombstones] — the document's tombstones, verbatim. We derive liveness from them but + * never author one, so dropping them on write would both lose another client's unknown + * keys and resurrect communities that client deliberately removed. + */ +class ConcordListResidue( + val extras: JsonObject = NoExtras, + val tombstones: List = emptyList(), +) { + companion object { + val EMPTY = ConcordListResidue() + } +} + +/** A decoded kind-13302 document: the live [entries] plus everything else it carried. */ +class ConcordCommunityListDocument( + val entries: List, + val residue: ConcordListResidue = ConcordListResidue.EMPTY, ) /** @@ -49,6 +121,18 @@ class PrivateChannelKey( * [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached * display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks * liveness against tombstones. + * + * [inviteRef] is the invite link this membership was joined through, kept in the + * domain-agnostic bare `#` form (Armada's `invite_ref`, CORD-05 + * §2/§3). It is the anchor for stranded recovery: a Refounding carries no + * recipient list, so a member left out of the rekey set never hears about the new + * epoch — re-resolving this link is the only way back. Entries joined without a + * link (direct invites, legacy entries) simply have none and are inert for + * recovery. [excludedAtEpoch] records the epoch at which we observed ourselves + * excluded, if ever. + * + * [residue] is the unmodelled part of the wire entry. Every copy of an entry MUST + * carry it forward, or the next write deletes another client's data. */ @Serializable class ConcordCommunityListEntry( @@ -62,6 +146,9 @@ class ConcordCommunityListEntry( val relays: List = emptyList(), val name: String = "", val addedAt: Long = 0, + val inviteRef: String? = null, + val excludedAtEpoch: Long? = null, + @Transient val residue: ConcordEntryResidue = ConcordEntryResidue.EMPTY, ) /** @@ -75,13 +162,68 @@ class ConcordCommunityListEntry( * "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where * [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived — * an entry is dropped only when a later tombstone removes it — and each entry keeps a - * [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current] - * (latest) snapshot; we hydrate from `current`, falling back to `seed`. + * `seed` (backfill anchor) plus `current` (latest) snapshot; we hydrate from `current`, + * falling back to `seed`. * * (Channels are not listed here beyond their private keys: once the [root] is held, * folding the Control Plane yields the community's channels.) + * + * ## Unknown keys are data, not noise + * + * Armada's entry type ends in `[k: string]: unknown`: unknown keys are part of the + * contract, and a client is expected to hand them back untouched. [ConcordJson] sets + * `ignoreUnknownKeys = true` precisely *because* "entity shapes are deliberately + * client-extensible" — but ignoring on decode plus a closed DTO on encode means we + * silently strip every extension we don't model, for every community, on every write. + * The format is designed for extension and we were deleting the extensions. + * + * So every wire DTO here is decoded through [ExtrasPreserving], which lifts the known + * fields and parks the remainder in an `__extras` bag that encode merges back + * (known fields win on conflict). The bags exist at **every** level — document, entry, + * `current` join material, each channel, each held root — because a top-level-only + * catch-all would still destroy everything nested inside, which is where the data is. + * `seed` and `tombstones` are kept verbatim instead (see [ConcordEntryResidue] / + * [ConcordListResidue]), which preserves them at every depth by construction. + * + * This is also why [JoinMaterialWire] has no `refounder` field: it used to be typed, + * parsed, never mapped into the domain, and therefore destroyed on the first write. + * It now rides the generic extras path like any other client extension. */ object ConcordCommunityList { + // ---- unknown-key preservation -------------------------------------------- + + /** Where an unknown key hides between decode and encode. Never appears on the wire. */ + private const val EXTRAS = "__extras" + + /** + * Wraps a generated serializer so unknown keys survive a decode → modify → encode. + * + * On decode, keys the DTO does not declare are moved into its `__extras` property; on + * encode they are merged back as siblings, with the declared fields winning any name + * collision. The known-key set is read off the delegate's descriptor rather than + * hand-listed, so it can never drift from the DTO. + */ + private open class ExtrasPreserving( + delegate: KSerializer, + ) : JsonTransformingSerializer(delegate) { + @OptIn(ExperimentalSerializationApi::class) + private val known = delegate.descriptor.elementNames.toSet() - EXTRAS + + override fun transformDeserialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj.filterKeys { it !in known } + if (extras.isEmpty()) return obj + return JsonObject(obj.filterKeys { it in known } + (EXTRAS to JsonObject(extras))) + } + + override fun transformSerialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj[EXTRAS]?.jsonObject ?: return obj + // Known fields win: they are applied on top of the preserved unknowns. + return JsonObject(extras + (obj - EXTRAS)) + } + } + // ---- wire DTOs (snake_case, Armada communityList.ts) ---------------------- @Serializable @@ -90,12 +232,14 @@ object ConcordCommunityList { val key: String, val epoch: Long, val name: String = "", + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) @Serializable private class WireHeldRoot( val epoch: Long, val key: String, + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) @Serializable @@ -105,33 +249,54 @@ object ConcordCommunityList { @SerialName("owner_salt") val ownerSalt: String, @SerialName("community_root") val communityRoot: String, @SerialName("root_epoch") val rootEpoch: Long, - val channels: List = emptyList(), + val channels: List< + @Serializable(WireChannelSerializer::class) + WireChannel, + > = emptyList(), val relays: List = emptyList(), val name: String = "", - @SerialName("held_roots") val heldRoots: List = emptyList(), - val refounder: String? = null, + @SerialName("held_roots") val heldRoots: List< + @Serializable(WireHeldRootSerializer::class) + WireHeldRoot, + > = emptyList(), + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) @Serializable private class CommunityListEntryWire( @SerialName("community_id") val communityId: String, - val seed: JoinMaterialWire? = null, - val current: JoinMaterialWire? = null, + /** Kept as raw JSON: the join anchor is never rewritten, only handed back. */ + val seed: JsonObject? = null, + @Serializable(JoinMaterialWireSerializer::class) val current: JoinMaterialWire? = null, @SerialName("added_at") val addedAt: Long = 0, - ) - - @Serializable - private class CommunityTombstoneWire( - @SerialName("community_id") val communityId: String, - @SerialName("removed_at") val removedAt: Long = 0, + @SerialName("invite_ref") val inviteRef: String? = null, + @SerialName("excluded_at_epoch") val excludedAtEpoch: Long? = null, + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) @Serializable private class CommunityListDoc( - val entries: List = emptyList(), - val tombstones: List = emptyList(), + val entries: List< + @Serializable(CommunityListEntryWireSerializer::class) + CommunityListEntryWire, + > = emptyList(), + /** Kept as raw JSON: we read tombstones but never author one. */ + val tombstones: List = emptyList(), + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) + private object WireChannelSerializer : ExtrasPreserving(WireChannel.serializer()) + + private object WireHeldRootSerializer : ExtrasPreserving(WireHeldRoot.serializer()) + + private object JoinMaterialWireSerializer : ExtrasPreserving(JoinMaterialWire.serializer()) + + private object CommunityListEntryWireSerializer : ExtrasPreserving(CommunityListEntryWire.serializer()) + + private object CommunityListDocSerializer : ExtrasPreserving(CommunityListDoc.serializer()) + + // ---- wire <-> domain ------------------------------------------------------ + private fun ConcordCommunityListEntry.toJoinMaterial() = JoinMaterialWire( communityId = id, @@ -139,25 +304,33 @@ object ConcordCommunityList { ownerSalt = ownerSalt, communityRoot = root, rootEpoch = rootEpoch, - channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) }, + channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name, it.extras) }, relays = relays, name = name, - heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) }, + heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key, it.extras) }, + extras = residue.currentExtras, ) - private fun JoinMaterialWire.toEntry(addedAt: Long) = - ConcordCommunityListEntry( - id = communityId, - owner = owner, - ownerSalt = ownerSalt, - root = communityRoot, - rootEpoch = rootEpoch, - heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) }, - privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, - relays = relays, - name = name, - addedAt = addedAt, - ) + private fun JoinMaterialWire.toEntry( + addedAt: Long, + inviteRef: String? = null, + excludedAtEpoch: Long? = null, + residue: ConcordEntryResidue = ConcordEntryResidue.EMPTY, + ) = ConcordCommunityListEntry( + id = communityId, + owner = owner, + ownerSalt = ownerSalt, + root = communityRoot, + rootEpoch = rootEpoch, + heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key, it.extras) }, + privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name, it.extras) }, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) // ---- build / codec -------------------------------------------------------- @@ -166,13 +339,20 @@ object ConcordCommunityList { signer: NostrSigner, entries: List, createdAt: Long, + residue: ConcordListResidue = ConcordListResidue.EMPTY, ): Event { - val content = signer.nip44Encrypt(encode(entries), signer.pubKey) + val content = signer.nip44Encrypt(encode(entries, residue), signer.pubKey) return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content) } - /** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */ - fun encode(entries: List): String { + /** + * Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted, + * handing back everything [residue] (and each entry's own residue) preserved on decode. + */ + fun encode( + entries: List, + residue: ConcordListResidue = ConcordListResidue.EMPTY, + ): String { val doc = CommunityListDoc( entries = @@ -180,14 +360,20 @@ object ConcordCommunityList { val jm = e.toJoinMaterial() CommunityListEntryWire( communityId = e.id, - seed = jm, + // The join anchor is immutable: re-emit the original when we have it, + // and only mint one from the current material for a brand new entry. + seed = e.residue.seed ?: ConcordJson.instance.encodeToJsonElement(JoinMaterialWireSerializer, jm).jsonObject, current = jm, addedAt = e.addedAt, + inviteRef = e.inviteRef, + excludedAtEpoch = e.excludedAtEpoch, + extras = e.residue.entryExtras, ) }, - tombstones = emptyList(), + tombstones = residue.tombstones, + extras = residue.extras, ) - return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc) + return ConcordJson.instance.encodeToString(CommunityListDocSerializer, doc) } /** @@ -195,21 +381,45 @@ object ConcordCommunityList { * failure. An entry is live unless a tombstone for the same community removed it * strictly after it was added; hydration prefers `current`, falling back to `seed`. */ - fun decode(json: String): List = + fun decode(json: String): List = decodeDocument(json).entries + + /** + * Parses the decrypted plaintext JSON document into its live entries plus the + * document-level residue (unknown keys and tombstones) that [encode] must hand back. + * Returns an empty document on failure. + */ + fun decodeDocument(json: String): ConcordCommunityListDocument = try { - val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json) + val doc = ConcordJson.instance.decodeFromString(CommunityListDocSerializer, json) val latestRemoval = HashMap() for (t in doc.tombstones) { - val prev = latestRemoval[t.communityId] - if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt - } - doc.entries.mapNotNull { e -> - val removedAt = latestRemoval[e.communityId] - if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null - (e.current ?: e.seed)?.toEntry(e.addedAt) + val id = (t["community_id"] as? JsonPrimitive)?.contentOrNull ?: continue + val removedAt = (t["removed_at"] as? JsonPrimitive)?.longOrNull ?: 0L + val prev = latestRemoval[id] + if (prev == null || removedAt > prev) latestRemoval[id] = removedAt } + val entries = + doc.entries.mapNotNull { e -> + val removedAt = latestRemoval[e.communityId] + if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null + val current = e.current + val seed = e.seed?.let { ConcordJson.instance.decodeFromJsonElement(JoinMaterialWireSerializer, it) } + // Hydrating from `seed` mints a fresh `current`; its unknown keys stay safe in + // the verbatim seed, so they are not copied into the new snapshot. + val residue = + ConcordEntryResidue( + entryExtras = e.extras, + seed = e.seed, + currentExtras = if (current != null) current.extras else NoExtras, + ) + (current ?: seed)?.toEntry(e.addedAt, e.inviteRef, e.excludedAtEpoch, residue) + } + ConcordCommunityListDocument( + entries = entries, + residue = ConcordListResidue(extras = doc.extras, tombstones = doc.tombstones), + ) } catch (_: Exception) { - emptyList() + ConcordCommunityListDocument(emptyList()) } /** Decrypts and parses a kind-13302 list event with [signer], or empty on failure. */ @@ -238,8 +448,34 @@ object ConcordCommunityList { val byId = LinkedHashMap() for (e in a + b) { val existing = byId[e.id] - if (existing == null || e.rootEpoch > existing.rootEpoch) byId[e.id] = e + if (existing == null) { + byId[e.id] = e + } else if (e.rootEpoch > existing.rootEpoch) { + // A winner without an invite_ref inherits the loser's: that link is the only anchor + // stranded recovery has, and dropping it on a merge would disarm recovery forever. + byId[e.id] = if (e.inviteRef == null) e.withInviteRef(existing.inviteRef) else e + } else if (existing.inviteRef == null && e.inviteRef != null) { + byId[e.id] = existing.withInviteRef(e.inviteRef) + } } return byId.values.toList() } + + /** Copy of this entry carrying [inviteRef]; every other field untouched. */ + fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt index 934841648a..fff6f69bbb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -51,11 +51,18 @@ class ConcordCommunityListEvent( override fun isContentEncoded() = true /** Decrypts this list's entries with [signer], or empty on failure / wrong key. */ - suspend fun decrypt(signer: NostrSigner): List = + suspend fun decrypt(signer: NostrSigner): List = decryptDocument(signer).entries + + /** + * Decrypts the whole document with [signer] — entries plus the residue (unknown keys and + * tombstones) that a read-modify-write must hand back untouched. Use this, not [decrypt], + * whenever the result will be re-encoded: [decrypt] discards the document-level residue. + */ + suspend fun decryptDocument(signer: NostrSigner): ConcordCommunityListDocument = try { - ConcordCommunityList.decode(signer.nip44Decrypt(content, signer.pubKey)) + ConcordCommunityList.decodeDocument(signer.nip44Decrypt(content, signer.pubKey)) } catch (_: Exception) { - emptyList() + ConcordCommunityListDocument(emptyList()) } companion object { @@ -70,8 +77,9 @@ class ConcordCommunityListEvent( signer: NostrSigner, entries: List, createdAt: Long = TimeUtils.now(), + residue: ConcordListResidue = ConcordListResidue.EMPTY, ): ConcordCommunityListEvent { - val content = signer.nip44Encrypt(ConcordCommunityList.encode(entries), signer.pubKey) + val content = signer.nip44Encrypt(ConcordCommunityList.encode(entries, residue), signer.pubKey) return signer.sign(createdAt, KIND, emptyArray(), content) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 83a44f55ee..da4b6c817a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -27,8 +27,10 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.asFloor /** A channel id paired with its current folded definition. */ class ConcordChannel( @@ -56,11 +58,71 @@ class ConcordCommunityState( val dissolved: Boolean, ) { companion object { + /** + * The permission bit an edition of each entity kind must be authored under. + * `null` means owner-only (no bit grants it). Mirrors the per-kind gating + * [fold] applies before the structural fold. + */ + private fun requiredPermission(kind: ControlEntityKind): Int? = + when (kind) { + ControlEntityKind.METADATA -> ConcordPermissions.MANAGE_METADATA + ControlEntityKind.CHANNEL -> ConcordPermissions.MANAGE_CHANNELS + ControlEntityKind.ROLE, ControlEntityKind.GRANT -> ConcordPermissions.MANAGE_ROLES + ControlEntityKind.BANLIST -> ConcordPermissions.BAN + ControlEntityKind.INVITE_LIVE, ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.INVITE_REVOKED -> ConcordPermissions.CREATE_INVITE + ControlEntityKind.DISSOLVED -> null + } + + /** + * The authority-gated structural head of **every** control entity, keyed by + * [ControlEdition.entityIdHex] — the source of the anti-rollback [EntityFloor]s a + * client carries across a CORD-06 Refounding. + * + * It is deliberately gated the same way [fold] gates each entity kind (and, for + * [ControlEntityKind.DISSOLVED], owner-only): an *ungated* head map would let any + * ex-member who still holds a rotated-out root mint a high-version edition on the + * old Control Plane and thereby raise our floor, freezing the entity for us. The + * floor must only ever remember editions we would actually have honored. + */ + fun authorizedHeads( + editions: Collection, + ownerPubKey: String, + floors: Map = emptyMap(), + ): Map { + val pool = EditionFold.admissible(editions, floors) + val authority = AuthorityResolver.resolve(pool, ownerPubKey) + val out = HashMap(floors) + for ((kind, list) in pool.groupBy { it.entityKind }) { + val bit = requiredPermission(kind) + // Gate the CANDIDATES, don't pre-filter the chain: a rejected edition mid-chain must + // stay inert instead of orphaning the authorized editions above it (EditionFold.candidates). + val heads = + EditionFold.foldGated(list, floors) { + authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit)) + } + for ((entity, head) in heads) { + // Monotonic: a floor only ever rises. Folding epoch by epoch, an entity the + // newer epoch never mentions keeps the version the older one reached. + val prior = out[entity] + if (prior == null || head.version >= prior.version) out[entity] = head.asFloor() + } + } + return out + } + fun fold( editions: Collection, ownerPubKey: String, + floors: Map = emptyMap(), ): ConcordCommunityState { - val heads = EditionFold.fold(editions).values + // Everything below folds a *derived* view of the same editions (the resolver's + // authority chains, the per-kind gated folds), so the anti-rollback floor is applied + // once, up front, on the shared pool: a rolled-back edition is never seen by any of + // them, and the head we already folded is re-seated so the entity keeps its state. + @Suppress("NAME_SHADOWING") + val editions = EditionFold.admissible(editions, floors) + + val heads = EditionFold.fold(editions, floors).values // Resolve authority from the FULL edition set (not the structural heads): the resolver // folds each role/grant chain through authorized editions only, so a rogue higher-version // edition can't supersede a legit one before authority is even judged. @@ -72,27 +134,29 @@ class ConcordCommunityState( // structural fold filters out spoofed editions — e.g. a decoy metadata genesis minted by // an unprivileged key — instead of letting a higher-version forgery win the chain. The // permission check also excludes banned authors (hasPermission is false for a banned npub). - fun editorsWith( + // The gate is applied to each entity's ORDERED CANDIDATES (chain head first, then the + // remaining editions version-descending), never as a pre-filter on the chain: dropping a + // rejected edition out of the middle of a chain permanently orphans every honest edition + // above it, freezing the entity. See EditionFold.candidates. + fun foldGatedBy( kind: ControlEntityKind, bit: Int, - ): List = - editions.filter { - it.entityKind == kind && (authority.isOwner(it.author) || authority.hasPermission(it.author, bit)) + ): Map = + EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors) { + authority.isOwner(it.author) || authority.hasPermission(it.author, bit) } - // Metadata is one entity (== community id), gated by MANAGE_METADATA. Fold only the - // authorized editions, then take the highest-version head (guarding against strays). + // Metadata is one entity (== community id), gated by MANAGE_METADATA. Take the + // highest-version gated head (guarding against strays). val metadata = - EditionFold - .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)) + foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA) .values .maxByOrNull { it.version } ?.let { ConcordJson.decodeOrNull(it.content) } - // Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized - // editions only, dropping the tombstoned ones. + // Channels are gated by MANAGE_CHANNELS, per channel entity, dropping the tombstoned ones. val channels = LinkedHashMap() - for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) { + for (head in foldGatedBy(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS).values) { val def = ConcordJson.decodeOrNull(head.content) ?: continue if (def.deleted) continue channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 7ddf32576d..8f67a24b5a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -145,6 +145,17 @@ class AuthorityResolver private constructor( return held.mapNotNull { roles[it]?.position }.minOrNull() } + // The bits a member currently holds, evaluated against the chain settled so far — the same + // owner-rooted basis as rankOf. Needed inside the fixpoint; effectivePermissionsOf below is + // the post-settlement view. + fun bitsOf(member: String): ConcordPermissions { + if (member == ownerLower) return ConcordPermissions.ALL + val held = memberRoles[member] ?: return ConcordPermissions.NONE + var acc = ConcordPermissions.NONE + for (id in held) roles[id]?.let { acc = acc union it.permissionBits() } + return acc + } + fun holdsManageRoles(member: String): Boolean { if (member == ownerLower) return true val held = memberRoles[member] ?: return false @@ -157,34 +168,63 @@ class AuthorityResolver private constructor( var pass = 0 while (pass++ <= maxPasses) { // Roles: a role edition is authorized when its author is the owner or holds MANAGE_ROLES. - // Fold each role chain through its authorized editions, then keep a live, ranked head. + // The gate is applied to the chain's ORDERED CANDIDATES, never used to pre-filter the + // chain — see EditionFold.candidates for why filtering first orphans honest editions. + fun roleGate( + entity: String, + e: ControlEdition, + ): Boolean { + val author = e.author.lowercase() + if (author == ownerLower) return true + if (!holdsManageRoles(author)) return false + val authorRank = rankOf(author) ?: return false + val r = ConcordJson.decodeOrNull(e.content) ?: return false + // MANAGE_ROLES alone was the whole test, which let any holder rewrite the + // role they hold — position 1 with every bit — and then demote the real + // admins beneath them. Grants are gated on rank (a granter must outrank + // what it hands out); role editions must be too, in both directions: + // - it may not claim a position at or above the author's own rank, and + // - it may not touch a role that already sits at or above them. + // A delete keeps only the second rule: you may retire a role beneath you. + val currentPosition = roles[entity]?.position + if (currentPosition != null && currentPosition <= authorRank) return false + if (!r.deleted && r.position <= authorRank) return false + // Nor may it grant bits the author does not itself hold, which would + // otherwise escalate through a role rather than through a grant. + return r.deleted || bitsOf(author).hasAll(r.permissionBits()) + } + val newRoles = HashMap() for ((entity, chain) in roleChains) { - val head = - EditionFold.foldEntity(chain.filter { it.author.lowercase() == ownerLower || holdsManageRoles(it.author.lowercase()) }) - ?: continue + val head = EditionFold.foldEntityGated(chain) { roleGate(entity, it) } ?: continue val r = ConcordJson.decodeOrNull(head.content) ?: continue if (r.deleted || r.position < 1) continue // no role may claim the owner's position 0 newRoles[entity] = r } // Grants: an edition is authorized when its granter is the owner, or holds MANAGE_ROLES - // AND strictly outranks every role it hands out. Fold each member's grant chain through - // its authorized editions so a rogue higher-version grant is dropped, not honored. + // AND strictly outranks every role it hands out. Same candidate-then-gate shape, so a + // rogue grant is dropped without orphaning the honest grants chained above it. + fun grantGate(e: ControlEdition): Boolean { + val granter = e.author.lowercase() + if (granter == ownerLower) return true + if (!holdsManageRoles(granter)) return false + val granterRank = rankOf(granter) ?: return false + val g = ConcordJson.decodeOrNull(e.content) ?: return false + // Must strictly outrank each assigned role that actually exists... + if (!g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false + // ...and outrank the member being edited. A grant is an action ON that + // member, and a REVOKE carries no role ids at all — `all {}` over an + // empty list is vacuously true, so without this any MANAGE_ROLES holder + // could strip anyone's roles, the owner's admins included. Demotion has + // to be at least as hard as promotion. + val targetRank = rankOf(g.member.lowercase()) + return targetRank == null || granterRank < targetRank + } + val newMemberRoles = HashMap>() for ((_, chain) in grantChains) { - val head = - EditionFold.foldEntity( - chain.filter { e -> - val granter = e.author.lowercase() - if (granter == ownerLower) return@filter true - if (!holdsManageRoles(granter)) return@filter false - val granterRank = rankOf(granter) ?: return@filter false - val g = ConcordJson.decodeOrNull(e.content) ?: return@filter false - // Must strictly outrank each assigned role that actually exists. - g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true } - }, - ) ?: continue + val head = EditionFold.foldEntityGated(chain, gate = ::grantGate) ?: continue val g = ConcordJson.decodeOrNull(head.content) ?: continue newMemberRoles[g.member.lowercase()] = g.roleIds.filter { newRoles.containsKey(it) }.toSet() } @@ -212,19 +252,86 @@ class AuthorityResolver private constructor( // Ancestors (superseded by the chain, including an unban's now-cleared target) are already // reflected by the head and must not be resurrected. This is CORD-06's "down-only healing": // a concurrent ban is never lost, while an on-chain unban still takes effect. - val authorizedBanlist = - editions.filter { - it.entityKind == ControlEntityKind.BANLIST && - (it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN)) - } + val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST } + + fun banGate(e: ControlEdition): Boolean = e.author.lowercase() == ownerLower || effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN) + val authorizedBanlist = allBanlist.filter(::banGate) + + // CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an + // admin cannot ban a peer admin"); §5 step 3 restates it. Only §4, which defines the + // Banlist, states the BAN-bit half alone — which is why every implementation (ours and + // Armada's) shipped the bit check without the rank check, letting the most junior BAN + // holder ban the admins above them and the owner. See + // docs/concord-banlist-rank-conformance.md. + // + // The rule is stated per TARGET, but the Banlist is one whole-list document, so it is + // enforced as a DELTA rule: an edition may only add or remove npubs its signer strictly + // outranks. Entries it may not act on are ignored and the rest of the edition applies — + // rejecting the whole edition would discard the bulk-ban §4 recommends as the collision + // remedy, and would let a rogue grief the list by forcing rejections. + fun canBanTarget( + author: String, + target: String, + ): Boolean { + // Position 0 is "supreme and unremovable" (§2) and nothing may outrank it, so the + // owner is never a valid target — not even for themselves. + if (target == ownerLower) return false + if (author == ownerLower) return true + if (!effectivePermissionsOf(author).has(ConcordPermissions.BAN)) return false + val authorRank = rankOf(author) ?: return false + val targetRank = rankOf(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority + return authorRank < targetRank + } + + val byHash = allBanlist.associateBy { it.hashHex } + val effective = HashMap>() + + // The list an edition actually establishes: its parent's effective list, plus only the + // additions its signer may make and minus only the removals its signer may make. Walks + // the parent chain, so it is memoized; `visiting` also terminates a prevHash cycle. + fun effectiveList( + edition: ControlEdition, + visiting: MutableSet, + ): Set { + effective[edition.hashHex]?.let { return it } + if (!visiting.add(edition.hashHex)) return emptySet() + + val parent = edition.prevHash?.toHexKey()?.let { byHash[it] } + val base = parent?.let { effectiveList(it, visiting) } ?: emptySet() + val author = edition.author.lowercase() + val claimed = ConcordJson.decodeBanlist(edition.content)?.mapTo(HashSet()) { it.lowercase() } + + // A malformed body changes nothing rather than clearing the list. + val result = + if (claimed == null) { + base + } else { + val out = HashSet(base) + for (added in claimed - base) if (canBanTarget(author, added)) out.add(added) + for (removed in base - claimed) if (canBanTarget(author, removed)) out.remove(removed) + out + } + + visiting.remove(edition.hashHex) + effective[edition.hashHex] = result + return result + } + val banned = HashSet() - val banHead = EditionFold.foldEntity(authorizedBanlist) + // Candidate-then-gate, like roles and grants: an unauthorized banlist edition in the + // middle of the chain must not orphan the authorized ones chained above it (which, on + // a banlist, would silently resurrect every ban a later unban had cleared). + val banHead = EditionFold.foldEntityGated(allBanlist, gate = ::banGate) if (banHead != null) { - ConcordJson.decodeBanlist(banHead.content)?.forEach { banned.add(it.lowercase()) } - val ancestry = banlistAncestry(banHead, authorizedBanlist) + banned.addAll(effectiveList(banHead, HashSet())) + // Ancestry is a STRUCTURAL fact, so it is walked over the full pool: an unauthorized + // edition on the head's back-chain still supersedes what is beneath it, and walking + // only the authorized subset would stop there and mis-read those genuine ancestors as + // concurrent forks — un-doing the unban the chain already recorded. + val ancestry = banlistAncestry(banHead, allBanlist) for (edition in authorizedBanlist) { if (edition.hashHex !in ancestry) { - ConcordJson.decodeBanlist(edition.content)?.forEach { banned.add(it.lowercase()) } + banned.addAll(effectiveList(edition, HashSet())) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 3b8e3ff2ea..5d37555d66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -21,6 +21,43 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log + +/** + * The anti-rollback floor for one Control Plane entity: the [version] and + * [hashHex] of an edition this client already folded to, plus (when we still hold + * it) that [known] edition itself. + * + * A CORD-06 Refounding compacts the Control Plane by re-wrapping **one edition per + * entity** at the new epoch, and the *rotator* picks which one. Nothing in a + * signature stops it from re-wrapping version 1 of a chain that had reached + * version 2 — restoring a revoked role, clearing a banlist, reverting metadata — + * because every edition it serves is genuine. This is rollback by omission, and + * the only defense is memory: a client that already folded to v2 must refuse to + * come back down. [EntityFloor] is that memory, and [EditionFold.foldEntity] / + * [EditionFold.admissible] are where it is enforced. + * + * [known] is what "keeps its existing state" means concretely: when the offered + * chain cannot be connected to the floor, we fall back to the edition we last + * folded rather than letting the entity vanish (an entity vanishing from the fold + * is itself a rollback — a dropped banlist is an unban). + */ +class EntityFloor( + val version: Long, + val hashHex: String, + val known: ControlEdition? = null, +) + +/** This edition as an anti-rollback floor for its entity. */ +fun ControlEdition.asFloor(): EntityFloor = EntityFloor(version, hashHex, this) + +/** + * Called when an entity's offered chain cannot be connected to the floor this + * client already holds — i.e. someone tried to move the entity backwards. The + * arguments are the entity id, the floor version we refuse to drop below, and the + * highest version offered. + */ +typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion: Long) -> Unit /** * Folds Control Plane editions into the current head of each entity (CORD-04 @@ -29,7 +66,14 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * Rules enforced here: * - **Genesis anchoring** — a chain starts at the lowest-version edition with no * `ep` (prev hash). - * - **Refounding fallback (fresh joiner)** — when no genesis is present, anchor + * - **Anti-rollback floor** — when the caller supplies an [EntityFloor] (an + * entity head it already folded), the walk is *anchored at that floor*: it must + * find the exact edition (version + hash) it already knew. If it cannot, the + * offered chain is a **gap** and nothing above the floor is adopted — the entity + * keeps [EntityFloor.known] instead. This is what stops a Refounding rotator + * from serving v1 of a chain that had reached v2 (see [EntityFloor]). + * - **Refounding fallback (fresh joiner)** — when no genesis is present *and no + * floor is held*, anchor * at the lowest-version edition available and accept it as the baseline. After * a Refounding (CORD-06 §3) the compacted head still carries the `ep` it had * before compaction, citing an edition in the *prior* epoch that a fresh joiner @@ -51,36 +95,86 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * this structural fold; this class is purely the chain walk. */ object EditionFold { - /** Groups mixed [editions] by entity id and folds each to its head. */ - fun fold(editions: Collection): Map { + private const val TAG = "ConcordEditionFold" + + /** + * The default [GapReporter]: a rollback refusal is security-relevant (a rotator + * tried to revert an entity), so it is warned, never swallowed. + */ + val LOG_GAP: GapReporter = { entityIdHex, floorVersion, offeredVersion -> + Log.w(TAG) { + "Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it" + } + } + + /** + * Groups mixed [editions] by entity id and folds each to its head, honoring the + * per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]). + * + * Only entities actually present in [editions] are folded — re-seating an entity + * that was omitted entirely (the cheapest rollback of all) is [admissible]'s job, + * because it runs once on the whole pool, while this is also called on per-kind + * subsets that must not have other kinds' heads injected into them. + */ + fun fold( + editions: Collection, + floors: Map = emptyMap(), + onGap: GapReporter = LOG_GAP, + ): Map { val byEntity = editions.groupBy { it.entityIdHex } val out = HashMap(byEntity.size) for ((entity, list) in byEntity) { - foldEntity(list)?.let { out[entity] = it } + foldEntity(list, floors[entity], onGap)?.let { out[entity] = it } } return out } - /** Folds the editions of a single entity into its current head, or null. */ - fun foldEntity(editions: List): ControlEdition? { - if (editions.isEmpty()) return null + /** + * Folds the editions of a single entity into its current head, or null. + * + * With no [floor] this is the fresh-joiner fold: genesis-anchored, falling back + * to the lowest-version edition present (the compaction bootstrap). With a + * [floor] the walk is anchored at the exact edition already folded; if that + * edition is not among [editions] the chain is **gapped** and nothing above the + * floor is adopted — [EntityFloor.known] is kept instead (or null when we no + * longer hold it). A head is therefore never below the floor version. + */ + fun foldEntity( + editions: List, + floor: EntityFloor? = null, + onGap: GapReporter = LOG_GAP, + ): ControlEdition? { + if (editions.isEmpty()) return floor?.known // Index editions by version, keeping the tie-break winner where several // share a version (lower rumor id wins). val byVersion = HashMap>() for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) - // Anchor at the genesis (lowest version with no prev hash), preferring the - // tie-break winner. When no genesis is present — the compacted head of a - // Refounded community carries a prev citing the prior epoch — a fresh joiner - // anchors at the lowest-version edition it does hold and accepts it as the - // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. var head = - editions - .filter { it.prevHash == null } - .minWithOrNull(compareBy({ it.version }, { it.rumorId })) - ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) - ?: return null + if (floor != null) { + // Anchored at what we already folded: the offered set MUST contain that exact + // edition (same version AND same hash — a same-version sibling is a fork, not + // our chain). Failing that, refuse to move at all rather than accept an + // unverifiable jump; walking up from the floor also makes a head below the + // floor version structurally impossible. + editions.firstOrNull { it.version == floor.version && it.hashHex == floor.hashHex } + ?: run { + onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version }) + return floor.known + } + } else { + // Anchor at the genesis (lowest version with no prev hash), preferring the + // tie-break winner. When no genesis is present — the compacted head of a + // Refounded community carries a prev citing the prior epoch — a fresh joiner + // anchors at the lowest-version edition it does hold and accepts it as the + // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. + editions + .filter { it.prevHash == null } + .minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: return null + } // Walk the chain upward while the next version chains from the current head. while (true) { @@ -93,4 +187,130 @@ object EditionFold { } return head } + + /** + * One entity's [editions] as an **ordered candidate list** for its head: + * + * 1. the chain-verified [foldEntity] head first — the steady-state answer, and + * the compaction bootstrap too; + * 2. then every remaining edition, version-**descending** (ties by the lower + * rumor id, the fold's tie-break winner first). + * + * The caller layers its authority gate on top and takes the first candidate that + * passes ([foldGated]). That ordering is the whole point: an edition that fails + * the gate must be *skipped*, never allowed to truncate the chain. + * + * Filtering the unauthorized editions out **before** the walk is what CORD-04 + * §1 ("an edition whose signer isn't authorized is dropped") reads like, but it + * is a fork bomb: `foldEntity` only advances to `version + 1` when that edition + * cites the current head's hash, so deleting a rejected edition from the middle + * of the chain orphans every honest edition above it — permanently, since the + * honest editions keep citing it. One unauthorized edition anywhere in an + * entity's history would freeze that entity for good (a member's roles, a + * channel, the banlist), recoverable only by a CORD-06 Refounding. Walking the + * *unfiltered* chain for priority and gating the candidates instead keeps the + * rejected edition inert while its honest successors still resolve — the same + * shape Armada's `headCandidates` + `pickHead` use, so the two clients converge. + * + * The rogue-higher-version hole stays closed because the gate still decides: a + * forged edition is never admissible at any position, and the chain-verified + * head outranks any dangling higher version. + * + * [floor] semantics match [foldEntity]: anchored at the floor edition, and on a + * gap nothing above the floor is offered — only [EntityFloor.known]. + */ + fun candidates( + editions: List, + floor: EntityFloor? = null, + onGap: GapReporter = LOG_GAP, + ): List { + val head = foldEntity(editions, floor, onGap) ?: return emptyList() + // A gap re-seated the known head: nothing from the offered set is admissible above + // the floor, so the known edition is the only candidate. + if (floor != null && editions.none { it.version == floor.version && it.hashHex == floor.hashHex }) { + return listOf(head) + } + val out = ArrayList(editions.size) + out.add(head) + editions + .filterTo(ArrayList()) { it.rumorId != head.rumorId && (floor == null || it.version >= floor.version) } + .sortedWith(compareByDescending { it.version }.thenBy { it.rumorId }) + .let(out::addAll) + return out + } + + /** + * The head of one entity: the highest-priority [candidates] entry that passes + * [gate], or null when none does. See [candidates] for why the gate is applied + * *after* the chain walk rather than before it. + */ + fun foldEntityGated( + editions: List, + floor: EntityFloor? = null, + onGap: GapReporter = LOG_GAP, + gate: (ControlEdition) -> Boolean, + ): ControlEdition? = candidates(editions, floor, onGap).firstOrNull(gate) + + /** + * Groups mixed [editions] by entity id and folds each to the highest-priority + * head passing [gate] — the gated counterpart of [fold]. See [candidates]. + */ + fun foldGated( + editions: Collection, + floors: Map = emptyMap(), + onGap: GapReporter = LOG_GAP, + gate: (ControlEdition) -> Boolean, + ): Map { + val byEntity = editions.groupBy { it.entityIdHex } + val out = HashMap(byEntity.size) + for ((entity, list) in byEntity) { + foldEntityGated(list, floors[entity], onGap, gate)?.let { out[entity] = it } + } + return out + } + + /** + * The subset of [editions] a client holding [floors] may consider at all — the + * pre-filter for the layers that fold *derived* views of the same editions + * (authority resolution, per-kind gated folds) and therefore cannot each carry + * the floor themselves. + * + * Per entity: if the offered set contains the floor edition, the chain connects + * and everything is admissible. If it does not, the entity is **gapped** and + * every offered edition at or above the floor version is dropped, with + * [EntityFloor.known] substituted so the entity keeps the state we last folded. + * Entities with no floor pass through untouched (a fresh joiner must not be + * penalized for having no history). + */ + fun admissible( + editions: Collection, + floors: Map, + onGap: GapReporter = LOG_GAP, + ): List { + if (floors.isEmpty()) return editions.toList() + + val out = ArrayList(editions.size + floors.size) + val seen = HashSet(floors.size) + for ((entity, list) in editions.groupBy { it.entityIdHex }) { + seen.add(entity) + val floor = floors[entity] + if (floor == null) { + out.addAll(list) + continue + } + if (list.any { it.version == floor.version && it.hashHex == floor.hashHex }) { + out.addAll(list) + continue + } + onGap(entity, floor.version, list.maxOf { it.version }) + // Below the floor is history we already absorbed; at or above it is the jump we + // refuse. Re-seat the known head so the entity's state is kept, not cleared. + list.filterTo(out) { it.version < floor.version } + floor.known?.let { out.add(it) } + } + for ((entity, floor) in floors) { + if (entity !in seen) floor.known?.let { out.add(it) } + } + return out + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 6feab287b2..ee528efb5b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils /** * What the events fetched at an invite's addressable coordinate `(33301, @@ -50,6 +51,16 @@ sealed interface InviteBundleStatus { /** The newest event at the coordinate is a `vsk=9` revocation tombstone — the link was retired. */ data object Revoked : InviteBundleStatus + /** + * A `vsk=6` bundle that opened and validated, but whose `expires_at` is in the past. + * The [invite] is still carried so a preview can render what the link *would* have + * opened; joining must be refused (CORD-05 — an expiry that nobody enforces is + * decorative). + */ + data class Expired( + val invite: CommunityInvite, + ) : InviteBundleStatus + /** * Something is at the coordinate, but it isn't a `vsk=6` bundle this client can open — * a wrong/expired token, or a sub-kind (e.g. a mis-posted registry `vsk=8`) or format @@ -120,21 +131,47 @@ object ConcordInviteBundle { * can't resurrect a retired link). Otherwise the first `vsk=6` bundle that opens + * validates with [token] is [InviteBundleStatus.Live]; anything else present is * [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent]. + * + * An opened bundle whose `expires_at` has passed (compared against [nowMs], unix + * milliseconds) resolves to [InviteBundleStatus.Expired] rather than + * [InviteBundleStatus.Live], so the expiry is actually enforced at the one place + * every redeeming client already funnels through. */ fun classify( wraps: List, token: ByteArray, + nowMs: Long = TimeUtils.nowMillis(), ): InviteBundleStatus { val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked val invite = wraps.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } } - return if (invite != null) InviteBundleStatus.Live(invite) else InviteBundleStatus.Unreadable + return when { + invite == null -> InviteBundleStatus.Unreadable + isExpired(invite, nowMs) -> InviteBundleStatus.Expired(invite) + else -> InviteBundleStatus.Live(invite) + } } /** - * Validates that an [invite]'s owner + salt actually reproduce its - * community_id (CORD-02 self-certification), so a bundle can't smuggle a false - * owner or a fake key for a real community. + * Validates that an [invite]'s owner + salt actually reproduce its community_id + * (CORD-02 self-certification), so a bundle cannot smuggle a false OWNER. + * + * It does NOT bind `community_root`, and nothing here proves the bundle's minter is + * a member of the community it names. `community_id` commits only to (owner, salt) — + * both public in any invite — so an attacker can mint a bundle carrying a real + * community's id, owner and salt alongside a root of their own. A joiner adopts that + * root, believes they are in the real community, and posts into planes the attacker + * can read. + * + * This is a CORD-05 limitation rather than an implementation gap: Armada's + * `validateBundle` checks exactly the same thing and likewise leaves the root + * unbound, so a stricter unilateral rule here would break interop without + * protecting anyone. Verifying the adopted root's control plane does not close it + * either — sealed editions carry the owner's own signature, so an attacker can + * re-wrap genuine owner editions into their plane, which is precisely what a + * legitimate compaction does. Closing it needs a spec change: commit the root into + * the self-certifying id, or require the bundle to be signed by a roster-authorized + * member. Raise with the Concord/Armada authors before diverging. */ fun validate(invite: CommunityInvite): Boolean { val owner = invite.owner.hexToByteArrayOrNull() ?: return false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt index dff968e91f..ef85bd6852 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -169,7 +169,12 @@ object ConcordInviteLink { return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}" } - /** Parses a full invite URL back into its pointer + fragment, or null if malformed. */ + /** + * Parses an invite link back into its pointer + fragment, or null if malformed. + * Accepts both the full `{base}/invite/{naddr}#{fragment}` URL and the + * domain-agnostic bare `{naddr}#{fragment}` form produced by [bareForm], so a + * link stored by one front end still resolves when shared through another. + */ fun parseUrl(url: String): ParsedInviteLink? { val hash = url.indexOf('#') if (hash < 0) return null @@ -180,10 +185,26 @@ object ConcordInviteLink { return null } val marker = url.indexOf("/invite/") - if (marker < 0) return null - val naddr = url.substring(marker + "/invite/".length, hash) + val naddr = + if (marker >= 0) { + url.substring(marker + "/invite/".length, hash) + } else { + // Bare `#` — no host, no path. + url.substring(0, hash) + } val parsed = NAddress.parse(naddr) ?: return null if (parsed.kind != ConcordInviteBundleEvent.KIND) return null return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment) } + + /** + * Reduces any invite link to the domain-agnostic bare `{naddr}#{fragment}` form + * (CORD-05 §2/§3 `invite_ref`), dropping any `https://host/invite/` prefix so a + * membership anchored through one front end still matches a link shared via + * another. Returns null if [url] is not a parseable invite link. + */ + fun bareForm(url: String): String? { + val parsed = parseUrl(url) ?: return null + return parsed.naddr + "#" + url.substring(url.indexOf('#') + 1) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt new file mode 100644 index 0000000000..1baf91cb5f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -0,0 +1,100 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot + +/** + * Stranded recovery (CORD-05/06). + * + * A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no + * recipient list** — so a member who is simply left out of the rekey recipient + * set receives nothing and is silently stranded on the dead epoch forever, while + * everyone else moves on. This is true of any member, the owner included, and + * cannot be prevented on the receive side. + * + * The way out is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its + * bundle at that same addressable coordinate, re-minted at the current epoch. So + * a member who re-resolves their own join link and finds a **higher** epoch than + * the one they hold knows they were left behind, and can merge forward. + * + * This object holds only the pure decision + merge; fetching and unlocking the + * bundle at the link is the caller's job. + */ +object ConcordStrandedRecovery { + /** + * True when [bundle], resolved at [entry]'s stored invite link, proves we were + * left behind: it must describe the same community and sit at a strictly higher + * epoch. Same or lower is a no-op (we are current, or the bundle is stale). + */ + fun isStranded( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): Boolean = + entry.inviteRef != null && + bundle.communityId.equals(entry.id, ignoreCase = true) && + bundle.rootEpoch > entry.rootEpoch + + /** + * Merges [entry] forward onto the higher-epoch [bundle], or returns null when + * there is nothing to do ([isStranded] is false) — so the caller can treat null + * as "stay put" without a second check. + * + * The merge is epoch-monotonic (it never moves backwards, by construction of + * [isStranded]) and preserves two things the naive "adopt the bundle" would + * destroy: + * + * - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we + * are left out of is recoverable too; and + * - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are + * leaving, so prior-epoch history the member legitimately holds stays + * derivable instead of going dark on catch-up. + */ + fun mergeForward( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? { + if (!isStranded(entry, bundle)) return null + + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + + return ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + heldRoots = held, + privateChannels = entry.privateChannels, + relays = if (bundle.relays.isNotEmpty()) bundle.relays else entry.relays, + name = entry.name.ifEmpty { bundle.name }, + addedAt = entry.addedAt, + inviteRef = entry.inviteRef, + // We were excluded from the epoch we were sitting on when we found the gap. + excludedAtEpoch = entry.rootEpoch, + // Unknown keys another client wrote are data we hold in trust: carry them forward, + // or this recovery write silently deletes them. + residue = entry.residue, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt index 09027f1736..980eee74cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt @@ -69,6 +69,26 @@ data class Nip05Id( return Nip05Id(name, domain) } + /** + * Lenient parser used by mention/text-rendering paths. Accepts either: + * - a full NIP-05 identifier (`name@domain.tld`), parsed as in [parse]; or + * - a bare domain (`domain.tld`), synthesized as the wildcard form + * `_@domain.tld` per NIP-05. + * + * Note that a bare-domain hit produces a [Nip05Id] whose [name] is `_`; + * use [hasLocalPart] / [toDisplayValue] when rendering. + */ + fun parseLenient(candidate: String): Nip05Id? { + val trimmed = candidate.trim() + if (trimmed.isEmpty()) return null + if (trimmed.contains('@')) return parse(trimmed) + // Bare-domain form: synthesize the wildcard local-part. + val lower = trimmed.lowercase() + if (!DOMAIN_REGEX.matches(lower)) return null + if (lower.substringAfterLast('.').all { it.isDigit() }) return null + return Nip05Id("_", lower) + } + fun assemble( name: String, domain: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index 973f288b45..2328aa3d5c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -59,12 +59,23 @@ import kotlinx.coroutines.sync.withLock * [NostrSigner] surface (`sign`, `nip04/44Encrypt/Decrypt`). Whichever signer * the user logged in with is the one that ultimately performs the work. * - * Authorization is delegated to [authorizer]: signing/encryption/decryption are - * gated, while public/harmless reads (`get_public_key`, `ping`, `get_relays`) - * always succeed. All failures — decryption, authorization, an unsupported - * method, or an exception from the signer — are turned into a - * [BunkerResponseError] carrying the request id, so the client always gets a - * reply it can correlate. + * Authorization is delegated to [authorizer] in two layers: + * - **pairing** ([Nip46RequestAuthorizer.isPaired]) gates the identity reads + * `get_public_key` and `get_relays`. Anyone who obtains the `bunker://` URI + * holds the transport pubkey and the NIP-44 conversation key, so without this + * gate they could ask an unpaired signer *which Nostr account it signs for* + * (and its inbox relay set) without ever knowing the pairing secret — which + * would defeat the transport/identity split the rest of the design maintains. + * `connect` is deliberately NOT gated (it is how pairing happens), and neither + * is `ping`: it only confirms that a signer is alive at a pubkey the caller + * already has, leaks no identity, and clients use it as a pre-flight liveness + * check, so gating it would risk breaking legitimate handshakes for no gain. + * - **per-operation consent** ([Nip46RequestAuthorizer.authorize]) gates + * signing/encryption/decryption. + * + * All failures — decryption, authorization, an unsupported method, or an + * exception from the signer — are turned into a [BunkerResponseError] carrying + * the request id, so the client always gets a reply it can correlate. * * Pairs with [NostrConnectSignerService], which subscribes to the relays, * decrypts each kind-24133 request, calls [process], and publishes the reply. @@ -100,12 +111,20 @@ class BunkerRequestProcessor( is Nip46ConnectDecision.Reject -> BunkerResponseError(request.id, decision.reason) } - is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + // Identity reads: only for a client that has already paired. See the class doc — an + // unpaired holder of the bunker URI must not be able to learn WHICH account this is. + is BunkerRequestGetPublicKey -> + ifPaired(clientPubKey, request) { + BunkerResponsePublicKey(request.id, signer.pubKey) + } + // Liveness only; answers with no identity at all, so it stays open (see class doc). is BunkerRequestPing -> BunkerResponsePong(request.id) is BunkerRequestGetRelays -> - BunkerResponseGetRelays(request.id, relays().associate { it.url to ReadWrite(read = true, write = true) }) + ifPaired(clientPubKey, request) { + BunkerResponseGetRelays(request.id, relays().associate { it.url to ReadWrite(read = true, write = true) }) + } is BunkerRequestSign -> ifAuthorized(clientPubKey, request) { @@ -148,6 +167,22 @@ class BunkerRequestProcessor( BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") } + /** + * Runs [block] only when [clientPubKey] has already paired with this signer (a successful + * `connect`). Used for the identity reads, which need no per-op consent but must not answer a + * stranger who merely holds the bunker URI. + */ + private suspend inline fun ifPaired( + clientPubKey: HexKey, + request: BunkerRequest, + block: () -> BunkerResponse, + ): BunkerResponse = + if (authorizer.isPaired(clientPubKey)) { + block() + } else { + BunkerResponseError(request.id, ERROR_NOT_CONNECTED) + } + private suspend inline fun ifAuthorized( clientPubKey: HexKey, request: BunkerRequest, @@ -172,6 +207,13 @@ class BunkerRequestProcessor( /** Error result returned when [Nip46RequestAuthorizer.authorize] denies a request. */ const val ERROR_UNAUTHORIZED: String = "unauthorized" + /** + * Error result returned to a client that has not paired ([Nip46RequestAuthorizer.isPaired]) + * when it asks for the signer's identity (`get_public_key`, `get_relays`). It must not reveal + * whether the account exists, so it says only that this client is not connected. + */ + const val ERROR_NOT_CONNECTED: String = "not connected" + /** Error result returned when the account can no longer sign (logged out / read-only / no signer). */ const val ERROR_ACCOUNT_UNAVAILABLE: String = "account unavailable" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt index 670208581b..1ee44400b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt @@ -35,10 +35,26 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect * client do this?". Everything here is `suspend` so an implementation may block * on disk, a live user prompt, or IPC without changing the processor. * - * Public, harmless requests (`get_public_key`, `ping`, `get_relays`) are NOT - * routed through [authorize]; only signing, encryption and decryption are. + * Two gates, not one: + * - [isPaired] answers "has this client completed a `connect`?" and guards the + * identity reads (`get_public_key`, `get_relays`) — they need no per-op + * consent, but they must not answer a stranger who merely obtained the + * `bunker://` URI, since the answer names the user's account. `ping` is not + * guarded (pure liveness), and `connect` obviously cannot be. + * - [authorize] gates signing, encryption and decryption. */ interface Nip46RequestAuthorizer { + /** + * `true` when [clientPubKey] has already paired with this signer — i.e. a `connect` succeeded + * and the client holds a standing grant. Guards the identity reads (`get_public_key`, + * `get_relays`), which would otherwise tell any holder of the bunker URI *which Nostr account* + * this signer belongs to without ever presenting the pairing secret. + * + * Deliberately has no default: every implementation must state its own pairing rule, so a new + * authorizer cannot silently inherit "everyone is paired". + */ + suspend fun isPaired(clientPubKey: HexKey): Boolean + /** * Called when a client sends a `connect` request. The implementation * validates the offered secret (the `bunker://…?secret=…` pairing token), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt index 63a80b0611..6ffd47cb8c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt @@ -43,7 +43,41 @@ data class BlossomPaymentRequired( /** True when the server offered at least one payment method we could attempt. */ fun hasPaymentOption(): Boolean = !cashu.isNullOrBlank() || !lightning.isNullOrBlank() + /** + * [reason] rendered safe to show in a dialog. + * + * `X-Reason` is server-controlled text displayed next to a Pay button, so a + * hostile server would otherwise use it to assert its own amount ("Pay 1 + * sat"), inject blank lines to push the real wording off screen, or run + * control characters / bidi overrides through the label. This strips every + * ISO control character (newlines, tabs, NUL) plus the Unicode bidi + * overrides, collapses the resulting whitespace, and clamps the length. + * + * The caller must still present the result as *the server's* words — never + * as Amethyst's own — because the content itself remains untrusted. + */ + fun sanitizedReason(maxLength: Int = MAX_REASON_LENGTH): String? { + val raw = reason ?: return null + val cleaned = + raw + .map { if (it.isISOControl() || it in BIDI_OVERRIDES) ' ' else it } + .joinToString("") + .replace(WHITESPACE_RUN, " ") + .trim() + + if (cleaned.isEmpty()) return null + return if (cleaned.length > maxLength) cleaned.take(maxLength).trimEnd() + "…" else cleaned + } + companion object { + /** Long enough for a real explanation, short enough that it can't crowd out our own text. */ + const val MAX_REASON_LENGTH = 200 + + /** LRE/RLE/PDF/LRO/RLO and the isolate family — invisible, and they reorder what follows. */ + private val BIDI_OVERRIDES = charArrayOf('‪', '‫', '‬', '‭', '‮', '⁦', '⁧', '⁨', '⁩', '‏', '‎') + + private val WHITESPACE_RUN = Regex("\\s+") + /** * Reads the BUD-07 headers from a 402 response. [header] returns the value * for a header name (case-insensitive at the transport layer), or null. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt index 5f5cf7c270..77cc66ad95 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt @@ -66,11 +66,18 @@ class PodcastValue( val active = recipients.filter { it.split > 0 && !it.address.isNullOrBlank() } if (active.isEmpty()) return emptyList() + // The value block is attacker-controlled: it comes verbatim from an episode event that anyone + // can publish, and `split` is an unvalidated Int. A fee is a PERCENTAGE off the top, so it is + // clamped to 100 individually and to whatever budget is left collectively — otherwise a single + // `fee:true, split:1000` recipient would be paid 10x the amount the user actually chose, every + // minute, while the on-screen running total still showed the intended figure. var feeTotalMillis = 0L val feeAmounts = HashMap() for (recipient in active) { if (recipient.fee == true) { - val millis = totalMilliSats * recipient.split / 100 + val percent = recipient.split.coerceAtMost(MAX_FEE_PERCENT) + val budgetLeft = totalMilliSats - feeTotalMillis + val millis = (totalMilliSats * percent / 100).coerceAtMost(budgetLeft) if (millis > 0) { feeAmounts[recipient] = millis feeTotalMillis += millis @@ -97,6 +104,9 @@ class PodcastValue( } companion object { + /** A fee recipient's split is a percentage off the top; anything above 100 is malformed. */ + const val MAX_FEE_PERCENT = 100 + /** * TLV record type for the Podcasting-2.0 keysend metadata blob (the "boostagram"), a JSON * object carrying podcast/episode/app/value context. Registered value, used by the whole diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index 9a9dea784d..ff379f5d19 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -24,9 +24,17 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue class ConcordCommunityListTest { @@ -133,6 +141,241 @@ class ConcordCommunityListTest { assertTrue(ConcordCommunityList.decode(json).isEmpty()) // removed after add ⇒ not live } + // ---- unknown-key preservation -------------------------------------------- + // + // Armada's communityList.ts entry type ends in `[k: string]: unknown` — unknown keys are + // part of the contract, and every write of ours must hand them back. A top-level-only + // catch-all would not be enough: most of the data lives inside `seed`/`current`. + + /** A document carrying an unmodelled key at every depth of the structure. */ + private fun docWithExtrasEverywhere() = + """ + { + "entries": [ + { + "community_id": "${"11".repeat(32)}", + "seed": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"bb".repeat(32)}", + "root_epoch": 0, + "channels": [], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox", + "x_seed": "seed-only" + }, + "current": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 2, + "channels": [ + { "id": "${"ee".repeat(32)}", "key": "${"dd".repeat(32)}", "epoch": 2, "name": "secret", "x_channel": "chan-only" } + ], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox", + "held_roots": [ { "epoch": 1, "key": "${"bb".repeat(32)}", "x_held": "held-only" } ], + "refounder": "${"99".repeat(32)}", + "x_current": "current-only" + }, + "added_at": 1700000000000, + "x_entry": "entry-only" + } + ], + "tombstones": [ + { "community_id": "${"22".repeat(32)}", "removed_at": 5, "x_tomb": "tomb-only" } + ], + "x_doc": "doc-only" + } + """.trimIndent() + + private fun String.asJson() = Json.parseToJsonElement(this).jsonObject + + private fun JsonObject.entry0() = this["entries"]!!.jsonArray[0].jsonObject + + @Test + fun unknownKeysSurviveAtEveryLevel() { + val doc = ConcordCommunityList.decodeDocument(docWithExtrasEverywhere()) + assertEquals(1, doc.entries.size) + + val out = ConcordCommunityList.encode(doc.entries, doc.residue).asJson() + + // document root + assertEquals("doc-only", out["x_doc"]?.jsonPrimitive?.content) + // tombstone (verbatim — we read tombstones but never author one) + val tomb = out["tombstones"]!!.jsonArray[0].jsonObject + assertEquals("tomb-only", tomb["x_tomb"]?.jsonPrimitive?.content) + assertEquals("22".repeat(32), tomb["community_id"]?.jsonPrimitive?.content) + + val entry = out.entry0() + // entry level + assertEquals("entry-only", entry["x_entry"]?.jsonPrimitive?.content) + // seed (the immutable join anchor, handed back untouched) + assertEquals("seed-only", entry["seed"]!!.jsonObject["x_seed"]?.jsonPrimitive?.content) + + val current = entry["current"]!!.jsonObject + // current join material + assertEquals("current-only", current["x_current"]?.jsonPrimitive?.content) + // nested channel + assertEquals( + "chan-only", + current["channels"]!! + .jsonArray[0] + .jsonObject["x_channel"] + ?.jsonPrimitive + ?.content, + ) + // nested held root + assertEquals( + "held-only", + current["held_roots"]!! + .jsonArray[0] + .jsonObject["x_held"] + ?.jsonPrimitive + ?.content, + ) + + // and none of the modelled data was disturbed on the way through + assertEquals("cc".repeat(32), current["community_root"]?.jsonPrimitive?.content) + assertEquals("Soapbox", current["name"]?.jsonPrimitive?.content) + assertEquals( + "secret", + current["channels"]!! + .jsonArray[0] + .jsonObject["name"] + ?.jsonPrimitive + ?.content, + ) + // the container the preserver uses internally never leaks onto the wire + assertFalse(out.toString().contains("__extras")) + } + + @Test + fun refounderSurvivesARoundTrip() { + // `refounder` is Armada's; nothing in Amethyst reads it. It used to be a typed field that + // was parsed, never mapped into the domain, and therefore destroyed on the first write. + val out = roundTrip(docWithExtrasEverywhere()) + val current = out.entry0()["current"]!!.jsonObject + assertEquals("99".repeat(32), current["refounder"]?.jsonPrimitive?.content) + } + + @Test + fun unknownKeysSurviveASecondRoundTrip() { + // Read-modify-write is not a one-shot: the preserved keys must still be there after the + // document has been through us twice. + val once = roundTrip(docWithExtrasEverywhere()) + val twice = roundTrip(once.toString()) + assertEquals("doc-only", twice["x_doc"]?.jsonPrimitive?.content) + assertEquals("entry-only", twice.entry0()["x_entry"]?.jsonPrimitive?.content) + assertEquals( + "seed-only", + twice + .entry0()["seed"]!! + .jsonObject["x_seed"] + ?.jsonPrimitive + ?.content, + ) + val current = twice.entry0()["current"]!!.jsonObject + assertEquals("current-only", current["x_current"]?.jsonPrimitive?.content) + assertEquals("99".repeat(32), current["refounder"]?.jsonPrimitive?.content) + assertEquals( + "chan-only", + current["channels"]!! + .jsonArray[0] + .jsonObject["x_channel"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "held-only", + current["held_roots"]!! + .jsonArray[0] + .jsonObject["x_held"] + ?.jsonPrimitive + ?.content, + ) + } + + private fun roundTrip(json: String): JsonObject { + val doc = ConcordCommunityList.decodeDocument(json) + return ConcordCommunityList.encode(doc.entries, doc.residue).asJson() + } + + @Test + fun modelledFieldWinsOverAPreservedKeyOfTheSameName() { + // A preserved bag must never shadow a field we model: if both carry `name`, the value we + // hold in the domain is the one that reaches the wire — exactly once. + val decoded = ConcordCommunityList.decodeDocument(docWithExtrasEverywhere()).entries[0] + val poisoned = + ConcordCommunityListEntry( + id = decoded.id, + owner = decoded.owner, + ownerSalt = decoded.ownerSalt, + root = decoded.root, + rootEpoch = decoded.rootEpoch, + heldRoots = decoded.heldRoots.map { HeldRoot(it.epoch, it.key, buildJsonObject { put("key", "STALE") }) }, + privateChannels = decoded.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name, buildJsonObject { put("name", "STALE") }) }, + relays = decoded.relays, + name = "Renamed", + addedAt = decoded.addedAt, + residue = + ConcordEntryResidue( + entryExtras = buildJsonObject { put("community_id", "STALE") }, + seed = decoded.residue.seed, + currentExtras = + buildJsonObject { + put("name", "STALE") + put("x_current", "kept") + }, + ), + ) + + val out = ConcordCommunityList.encode(listOf(poisoned)).asJson() + val entry = out.entry0() + assertEquals(decoded.id, entry["community_id"]?.jsonPrimitive?.content) + + val current = entry["current"]!!.jsonObject + assertEquals("Renamed", current["name"]?.jsonPrimitive?.content) + assertEquals("kept", current["x_current"]?.jsonPrimitive?.content) // non-colliding key untouched + assertEquals( + "secret", + current["channels"]!! + .jsonArray[0] + .jsonObject["name"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "bb".repeat(32), + current["held_roots"]!! + .jsonArray[0] + .jsonObject["key"] + ?.jsonPrimitive + ?.content, + ) + assertFalse(out.toString().contains("STALE")) + } + + @Test + fun tombstonesAreHandedBackInsteadOfDropped() { + // Dropping tombstones on write would resurrect communities another client deliberately + // removed, on top of losing whatever unknown keys they carried. + val out = roundTrip(docWithExtrasEverywhere()) + assertEquals(1, out["tombstones"]!!.jsonArray.size) + assertNotNull(out["tombstones"]!!.jsonArray[0].jsonObject["removed_at"]) + } + + @Test + fun aFreshEntryStillSeedsItsJoinAnchor() { + // No residue to hand back for a brand-new join: `seed` is minted from the current material. + val out = ConcordCommunityList.encode(listOf(entry("11".repeat(32), "New", epoch = 3))).asJson() + val entry = out.entry0() + assertEquals("bb".repeat(32), entry["seed"]!!.jsonObject["community_root"]?.jsonPrimitive?.content) + assertEquals("bb".repeat(32), entry["current"]!!.jsonObject["community_root"]?.jsonPrimitive?.content) + } + @Test fun mergeKeepsFreshestEpochPerCommunity() { val a = listOf(entry("11".repeat(32), "Old", epoch = 1)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt index 501e44697e..e4ad7e8718 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt @@ -70,6 +70,41 @@ class ConcordCommunityStateTest { assertFalse(state.dissolved) } + /** + * The per-kind gated folds share the shape that poisoned GRANT chains live: gating used + * to pre-filter the chain, so one unauthorized edition in the middle of a CHANNEL (or + * METADATA) chain orphaned every authorized edition above it — the channel frozen at its + * pre-attack name, permanently, with no way for the owner to rename or delete it. + */ + @Test + fun anUnauthorizedChannelOrMetadataEditionMidChainDoesNotOrphanTheEditionsAboveIt() { + val chan = "c1".repeat(32) + val meta = "00".repeat(32) + val troll = "77".repeat(32) // holds no roles at all + + fun chained( + kind: ControlEntityKind, + eid: String, + version: Long, + prev: ControlEdition?, + content: String, + author: String, + ) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, null, content, author, "r-$eid-$version", version) + + val c0 = chained(ControlEntityKind.CHANNEL, chan, 0, null, """{"name":"general"}""", owner) + val c1 = chained(ControlEntityKind.CHANNEL, chan, 1, c0, """{"name":"HACKED"}""", troll) + val c2 = chained(ControlEntityKind.CHANNEL, chan, 2, c1, """{"name":"renamed"}""", owner) + + val m0 = chained(ControlEntityKind.METADATA, meta, 0, null, """{"name":"My Server"}""", owner) + val m1 = chained(ControlEntityKind.METADATA, meta, 1, m0, """{"name":"HACKED"}""", troll) + val m2 = chained(ControlEntityKind.METADATA, meta, 2, m1, """{"name":"Renamed Server"}""", owner) + + val state = ConcordCommunityState.fold(listOf(c0, c1, c2, m0, m1, m2), owner) + + assertEquals("renamed", state.channels[chan]?.definition?.name, "the owner's v2 rename must apply") + assertEquals("Renamed Server", state.metadata?.name, "the owner's v2 metadata edit must apply") + } + @Test fun dissolutionTombstoneMarksCommunityDissolved() { val editions = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index c7c7b203dd..ec6ecae8c6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -85,6 +85,146 @@ class AuthorityResolverTest { 0, ) + @Test + fun aManageRolesHolderCannotPromoteItsOwnRoleAboveItself() { + // Grants are rank-gated but role editions were not: holding MANAGE_ROLES was the whole test. So + // a mid-tier moderator could rewrite the very role they hold — claiming position 1 and every + // permission bit — and in one more edition demote the real admins below them. + val modWithManageRoles = """{"name":"Mod","position":5,"permissions":"9"}""" // KICK|MANAGE_ROLES + val modV0 = role(modRole, modWithManageRoles) + val selfPromotion = + ControlEdition( + ControlEntityKind.ROLE, + modRole.hexToByteArray(), + 1, + modV0.hash, // chains correctly, so only the rank gate can stop it + null, + """{"name":"Mod","position":1,"permissions":"18446744073709551615"}""", + bob, // holds the role being edited + "role-$modRole-selfpromo", + 1, + ) + + val r = + AuthorityResolver.resolve( + listOf( + role(adminRole, adminJson), // position 1, owner-authored + modV0, // position 5, owner-authored + grant("31".repeat(32), alice, listOf(adminRole), granter = owner), + grant("32".repeat(32), bob, listOf(modRole), granter = owner), + selfPromotion, + ), + owner, + ) + + assertEquals(5, r.roles()[modRole]?.position, "Bob's self-promotion must not take effect") + assertFalse(r.effectivePermissions(bob).has(BAN), "Bob must not gain bits his role never held") + assertTrue(r.canActOn(alice, bob, KICK), "the real admin must still outrank the moderator") + assertFalse(r.canActOn(bob, alice, KICK), "the moderator must not gain authority over the admin") + } + + @Test + fun aRevokeCannotStripAMemberWhoOutranksTheGranter() { + // A revoke carries no role ids, and "outrank every assigned role" is vacuously true over an + // empty list — so without a check on the TARGET's standing rank, any MANAGE_ROLES holder could + // strip anyone, the owner's admins included. Armada gates this the same way: a grant is an + // action ON the member, so demotion must be at least as hard as promotion. + val modWithManageRoles = """{"name":"Mod","position":5,"permissions":"9"}""" // KICK|MANAGE_ROLES + val adminGrantId = "31".repeat(32) + val adminGrant = grant(adminGrantId, alice, listOf(adminRole), granter = owner) + val revokeByMod = + ControlEdition( + ControlEntityKind.GRANT, + adminGrantId.hexToByteArray(), + 1, + adminGrant.hash, + null, + """{"member":"$alice","role_ids":[]}""", // strip the admin + bob, // position 5, holds MANAGE_ROLES but is outranked by Alice + "grant-$adminGrantId-revoke", + 1, + ) + + val r = + AuthorityResolver.resolve( + listOf( + role(adminRole, adminJson), // position 1 + role(modRole, modWithManageRoles), // position 5 + adminGrant, + grant("32".repeat(32), bob, listOf(modRole), granter = owner), + revokeByMod, + ), + owner, + ) + + assertEquals(1L, r.rank(alice), "a moderator must not be able to demote an admin above it") + } + + @Test + fun anAdminCanStillRevokeAMemberBeneathIt() { + // The gate must not block legitimate moderation: an admin may revoke a moderator's roles. + val modGrantId = "32".repeat(32) + val modGrant = grant(modGrantId, bob, listOf(modRole), granter = owner) + val revokeByAdmin = + ControlEdition( + ControlEntityKind.GRANT, + modGrantId.hexToByteArray(), + 1, + modGrant.hash, + null, + """{"member":"$bob","role_ids":[]}""", + alice, // admin at position 1, outranks Bob at 5 + "grant-$modGrantId-revoke", + 1, + ) + + val r = + AuthorityResolver.resolve( + listOf( + role(adminRole, adminJson), + role(modRole, modJson), + grant("31".repeat(32), alice, listOf(adminRole), granter = owner), + modGrant, + revokeByAdmin, + ), + owner, + ) + + assertNull(r.rank(bob), "an admin may still revoke a moderator beneath it") + } + + @Test + fun aManageRolesHolderCanStillManageRolesBeneathIt() { + // The gate must not break legitimate delegation: an admin may still edit a role below its rank. + val modV0 = role(modRole, modJson) + val renamed = + ControlEdition( + ControlEntityKind.ROLE, + modRole.hexToByteArray(), + 1, + modV0.hash, + null, + """{"name":"Moderator","position":5,"permissions":"8"}""", + alice, // admin at position 1, outranks the role being edited + "role-$modRole-rename", + 1, + ) + + val r = + AuthorityResolver.resolve( + listOf( + role(adminRole, adminJson), + modV0, + grant("31".repeat(32), alice, listOf(adminRole), granter = owner), + renamed, + ), + owner, + ) + + assertEquals("Moderator", r.roles()[modRole]?.name, "an admin may edit a role beneath it") + assertEquals(5, r.roles()[modRole]?.position) + } + @Test fun ranksPermissionsAndActionAuthorityAreOwnerRooted() { val heads = @@ -242,4 +382,204 @@ class AuthorityResolverTest { val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner) assertEquals(1L, r.rank(alice)) // rogue v1 dropped; the owner's v0 grant stands } + + /** A chained edition of [kind] on [entityId], authored by [author]. */ + private fun edition( + kind: ControlEntityKind, + entityId: String, + version: Long, + prev: ControlEdition?, + content: String, + author: String, + rumorId: String, + ) = ControlEdition(kind, entityId.hexToByteArray(), version, prev?.hash, null, content, author, rumorId, version) + + private fun grantJson(roleIds: List) = """{"member":"$alice","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""" + + /** + * An unauthorized edition in the MIDDLE of an entity's chain must be inert, not + * chain-breaking. The resolver used to filter unauthorized editions out *before* the + * structural fold, so removing v2 left v3 citing a hash the walk no longer knew — the + * fold halted at v1 and v3/v4/v5 were orphaned FOREVER, since every honest edition + * after it keeps citing the rejected one. Any member could permanently freeze any + * member's roles (the owner's own ability to change them included) with one event, + * recoverable only by a CORD-06 Refounding. Observed live on a GRANT chain. + * + * Armada folds the same shape and is not poisoned: `headCandidates` walks the + * UNFILTERED chain for priority and `pickHead` takes the first candidate passing the + * authority gate, so it converges on v5 here. This must too. + */ + @Test + fun anUnauthorizedEditionMidChainDoesNotOrphanTheHonestEditionsAboveIt() { + val grantId = "ab".repeat(32) + val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(modRole)), owner, "g0") + val v1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(listOf(adminRole)), owner, "g1") + // carol holds ZERO roles — correctly rejected, at any position in the chain. + val v2 = edition(ControlEntityKind.GRANT, grantId, 2, v1, grantJson(listOf(adminRole)), carol, "g2") + val v3 = edition(ControlEntityKind.GRANT, grantId, 3, v2, grantJson(emptyList()), owner, "g3") + val v4 = edition(ControlEntityKind.GRANT, grantId, 4, v3, grantJson(listOf(adminRole)), owner, "g4") + val v5 = edition(ControlEntityKind.GRANT, grantId, 5, v4, grantJson(listOf(modRole)), owner, "g5") + + val r = + AuthorityResolver.resolve( + listOf(role(adminRole, adminJson), role(modRole, modJson), v0, v1, v2, v3, v4, v5), + owner, + ) + + assertEquals(setOf(modRole), r.rolesOf(alice), "the fold must reach v5, not stall at v1") + assertEquals(5L, r.rank(alice), "alice's standing rank is whatever the owner's LAST grant says") + } + + /** The same poisoning shape on a ROLE chain — the entity kinds share the fold. */ + @Test + fun anUnauthorizedRoleEditionMidChainDoesNotOrphanTheHonestEditionsAboveIt() { + fun mod( + position: Int, + perms: String, + ) = """{"name":"Mod","position":$position,"permissions":"$perms"}""" + + val v0 = edition(ControlEntityKind.ROLE, modRole, 0, null, mod(5, "8"), owner, "r0") + val v1 = edition(ControlEntityKind.ROLE, modRole, 1, v0, mod(5, "24"), owner, "r1") // +BAN + // dave holds no role at all: an escalation to position 1 with every bit, correctly rejected. + val v2 = edition(ControlEntityKind.ROLE, modRole, 2, v1, mod(1, "18446744073709551615"), dave, "r2") + val v3 = edition(ControlEntityKind.ROLE, modRole, 3, v2, mod(6, "8"), owner, "r3") + val v4 = edition(ControlEntityKind.ROLE, modRole, 4, v3, mod(7, "8"), owner, "r4") + + val r = + AuthorityResolver.resolve( + listOf(role(adminRole, adminJson), v0, v1, v2, v3, v4, grant("32".repeat(32), bob, listOf(modRole), granter = owner)), + owner, + ) + + assertEquals(7, r.roles()[modRole]?.position, "the fold must reach v4, not stall at v1") + assertFalse(r.effectivePermissions(bob).has(BAN), "v1's BAN bit was superseded by v3/v4") + assertEquals(7L, r.rank(bob)) + } + + /** + * The banlist variant — and the reason the ancestry walk must run over the FULL pool. + * With the rejected v2 absent from the ancestry walk it stops at v3, so v0/v1 read as + * *concurrent forks* and the healing union resurrects the ban the owner's v3 lifted. + */ + @Test + fun anUnauthorizedBanlistEditionMidChainDoesNotOrphanOrResurrectBans() { + val banId = "44".repeat(32) + + fun list(vararg keys: String) = "[${keys.joinToString(",") { "\"$it\"" }}]" + + val v0 = edition(ControlEntityKind.BANLIST, banId, 0, null, list(bob), owner, "b0") + val v1 = edition(ControlEntityKind.BANLIST, banId, 1, v0, list(bob, carol), owner, "b1") + val v2 = edition(ControlEntityKind.BANLIST, banId, 2, v1, list(), dave, "b2") // dave holds no BAN + val v3 = edition(ControlEntityKind.BANLIST, banId, 3, v2, list(carol), owner, "b3") // owner unbans bob + + val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner) + + assertTrue(r.isBanned(carol), "carol's ban survives to the head") + assertFalse(r.isBanned(bob), "the owner's v3 unban must apply — v2 may not orphan it") + } + + /** A forged edition takes effect at NO position: not at the tip, and not mid-chain. */ + @Test + fun aForgedEditionNeverTakesEffectAtAnyPosition() { + val grantId = "ab".repeat(32) + val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(adminRole)), owner, "g0") + // carol holds nothing; her revoke is the forgery, and it must apply at NO position. + val forgedV1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(emptyList()), carol, "g1") + val v2 = edition(ControlEntityKind.GRANT, grantId, 2, forgedV1, grantJson(listOf(modRole)), owner, "g2") + val base = listOf(role(adminRole, adminJson), role(modRole, modJson)) + + // Mid-chain: the honest v2 above it still resolves (this arm needs the fix), and the + // forged revoke never empties alice's roles. + val mid = AuthorityResolver.resolve(base + listOf(v0, forgedV1, v2), owner) + assertEquals(setOf(modRole), mid.rolesOf(alice)) + assertEquals(5L, mid.rank(alice)) + + // At the tip: the chain-verified head fails the gate, so the fold falls back to v0. + val tip = AuthorityResolver.resolve(base + listOf(v0, forgedV1), owner) + assertEquals(setOf(adminRole), tip.rolesOf(alice), "the forged revoke must not strip alice") + assertEquals(1L, tip.rank(alice)) + + // Above the tip, dangling: a higher version is never a shortcut past the gate. + val danglingV9 = edition(ControlEntityKind.GRANT, grantId, 9, null, grantJson(emptyList()), carol, "g9") + val above = AuthorityResolver.resolve(base + listOf(v0, danglingV9), owner) + assertEquals(setOf(adminRole), above.rolesOf(alice)) + assertEquals(1L, above.rank(alice)) + } + + // ---- Rank gating on the banlist (CORD-04 "equal cannot act on equal") ---- + // + // CORD-04 §3 binds "every action" to hold the required bit AND strictly outrank its target, and + // picks banning as its example ("an admin cannot ban a peer admin"); §5 step 3 restates it. Only + // §4, which defines the Banlist, states the bit half alone — which is why both this client and + // Armada shipped a rank-blind gate and let the most junior BAN holder ban the admins above them, + // and the owner. Because the Banlist is one whole-list document, the per-target rule is enforced + // as a delta rule: only additions and removals the signer outranks take effect, and the entries + // it may not act on are ignored rather than rejecting the edition wholesale. + // See docs/concord-banlist-rank-conformance.md — Armada has not shipped this yet, so banlists + // may differ between clients until it does. + + // A moderator that holds BAN but sits BELOW an admin. + private val modWithBanJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN + + private fun rankedBanScenario(vararg extra: ControlEdition) = + AuthorityResolver.resolve( + listOf( + role(adminRole, adminJson), // position 1 + role(modRole, modWithBanJson), // position 5, holds BAN + grant("31".repeat(32), alice, listOf(adminRole), granter = owner), + grant("32".repeat(32), bob, listOf(modRole), granter = owner), + ) + extra, + owner, + ) + + @Test + fun aBanHolderCannotBanAMemberItDoesNotOutrank() { + // A rank-5 moderator bans the rank-1 admin above them. Before the delta rule the fold + // accepted this — privilege escalation, not a no-op: the admin then lost every permission, + // because hasPermission() is `!isBanned && ..`. + val r = rankedBanScenario(banlistBy(bob, "mod-bans-admin", alice)) + + assertFalse(r.canActOn(bob, alice, BAN), "the rule itself: a moderator cannot act on an admin") + assertFalse(r.isBanned(alice), "so the fold must not honor the moderator's ban of the admin") + } + + @Test + fun aBanHolderCannotBanTheOwner() { + // The owner is unremovable (canActOn refuses them as a target), but the banlist is just a + // list of keys. Banning the owner does not cost them fold authority (banGate/authorizedHeads + // short-circuit on isOwner), yet hasPermission() is `!isBanned && ...`, so canActOn(owner, ..) + // goes false and the owner loses every rank-gated action. + val r = rankedBanScenario(banlistBy(bob, "mod-bans-owner", owner)) + + assertFalse(r.isBanned(owner), "the owner must never be bannable") + assertTrue(r.canActOn(owner, bob, BAN), "and must keep authority over everyone") + } + + @Test + fun aBanHolderStillBansThoseItOutranks() { + // The gate must not over-correct: carol holds no role at all (rank = lowest), so the + // moderator outranks her and the ban must land. + val r = rankedBanScenario(banlistBy(bob, "mod-bans-plain-member", carol)) + + assertTrue(r.canActOn(bob, carol, BAN)) + assertTrue(r.isBanned(carol), "a moderator must still ban a plain member") + } + + @Test + fun theOwnerBansAnyone() { + val r = rankedBanScenario(banlistBy(owner, "owner-bans-admin", alice)) + + assertTrue(r.isBanned(alice), "the owner outranks everyone") + } + + @Test + fun anUnrankedBanIsDroppedWithoutOrphaningTheRestOfTheList() { + // The moderator bans the admin AND a plain member in one edition. The edition is the head of + // the chain, so rejecting it wholesale would also lose the legitimate ban of carol. Only the + // entries the author does not outrank may be dropped. + val r = rankedBanScenario(banlistBy(bob, "mod-bans-both", alice, carol)) + + assertFalse(r.isBanned(alice), "the part it does not outrank is dropped") + assertTrue(r.isBanned(carol), "the part it does outrank still lands") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt new file mode 100644 index 0000000000..186311dea5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt @@ -0,0 +1,174 @@ +/* + * 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.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Anti-rollback: a CORD-06 Refounding re-wraps ONE edition per entity at the new epoch and the + * *rotator* chooses which one, so it can serve v1 of a chain that had reached v2 — restoring a + * revoked role, clearing a banlist, reverting metadata — with every signature genuine. Rollback + * by omission, not forgery. A client that already folded to v2 must refuse to come back down. + */ +class EditionFoldFloorTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + private val otherEid = ByteArray(32) { 0xCD.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + entityId: ByteArray = eid, + rumorId: String = "id-v$version", + ) = ControlEdition( + entityKind = ControlEntityKind.GRANT, + entityId = entityId, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + // A grant chain: v0 grants the role, v1 edits it, v2 revokes it. Rolling back to v1 restores + // a role the community already revoked. + private val v0 = edition(0, null, """{"member":"a","role_ids":["mod"]}""") + private val v1 = edition(1, v0.hash, """{"member":"a","role_ids":["admin"]}""") + private val v2 = edition(2, v1.hash, """{"member":"a","role_ids":[]}""") + private val v3 = edition(3, v2.hash, """{"member":"a","role_ids":["mod"]}""") + + private val floorAtV2 = v2.asFloor() + + // ---- foldEntity ---------------------------------------------------------- + + /** The attack: the rotator serves only v1, whose prev dangles (v0 was not re-wrapped). */ + @Test + fun refusesRollbackToAnOmittedLowerVersion() { + val gaps = mutableListOf>() + val head = EditionFold.foldEntity(listOf(v1), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) } + + assertEquals(v2.rumorId, head?.rumorId, "the revoked-at-v2 head must survive the rollback") + assertEquals(listOf(Triple(v2.entityIdHex, 2L, 1L)), gaps, "the refusal must be reported") + } + + /** Even an intact prefix (v0→v1) is a downgrade when we already folded v2. */ + @Test + fun refusesRollbackEvenWhenTheOfferedPrefixIsIntact() { + val head = EditionFold.foldEntity(listOf(v0, v1), floorAtV2) { _, _, _ -> } + assertEquals(v2.rumorId, head?.rumorId) + } + + /** A same-version forgery is a fork, not our chain: the floor matches on hash, not version. */ + @Test + fun refusesSiblingAtTheFloorVersion() { + val forgedV2 = edition(2, v1.hash, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forged") + val head = EditionFold.foldEntity(listOf(v1, forgedV2), floorAtV2) { _, _, _ -> } + assertEquals(v2.rumorId, head?.rumorId) + assertEquals(v2.content, head?.content) + } + + /** An honest compaction re-wraps the very head we hold: the walk connects, so it is adopted. */ + @Test + fun acceptsHonestCompactionAtTheFloor() { + val gaps = mutableListOf() + val head = EditionFold.foldEntity(listOf(v2), floorAtV2) { e, _, _ -> gaps += e } + assertEquals(v2.rumorId, head?.rumorId) + assertTrue(gaps.isEmpty(), "an honest compaction must not report a gap") + } + + /** And it advances past the floor when the new epoch chains forward from it. */ + @Test + fun advancesAboveTheFloorWhenTheChainConnects() { + val head = EditionFold.foldEntity(listOf(v3, v2), floorAtV2) { _, _, _ -> } + assertEquals(v3.rumorId, head?.rumorId) + } + + /** A floor whose known edition we no longer hold still refuses to move down — it adopts nothing. */ + @Test + fun refusesRollbackWithNoKnownEditionToFallBackOn() { + val hashOnlyFloor = EntityFloor(v2.version, v2.hashHex, known = null) + assertNull(EditionFold.foldEntity(listOf(v1), hashOnlyFloor) { _, _, _ -> }) + } + + /** + * A fresh joiner holds no floor and MUST still accept a dangling compacted head as its + * baseline (CORD-04 §1 / CORD-06 §3) — it legitimately has no history to fail closed on. + * Regression guard for `ControlEditionTest.foldWithoutGenesisAcceptsCompactedHead`. + */ + @Test + fun freshJoinerWithoutAFloorStillAcceptsADanglingCompactedHead() { + assertEquals(v1.rumorId, EditionFold.foldEntity(listOf(v1), floor = null)?.rumorId) + } + + // ---- fold (per-entity map) ----------------------------------------------- + + @Test + fun foldAppliesTheFloorPerEntity() { + val otherV0 = edition(0, null, """{"member":"b","role_ids":["mod"]}""", entityId = otherEid, rumorId = "other-v0") + val heads = EditionFold.fold(listOf(v1, otherV0), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + + assertEquals(v2.rumorId, heads[v2.entityIdHex]?.rumorId, "floored entity refuses the rollback") + assertEquals(otherV0.rumorId, heads[otherV0.entityIdHex]?.rumorId, "un-floored entity folds normally") + } + + // ---- admissible (the pre-filter the derived folds share) ------------------ + + /** A gapped entity loses every edition at or above the floor, and keeps the head we knew. */ + @Test + fun admissibleDropsTheRolledBackChainAndReSeatsTheKnownHead() { + val forgedV2 = edition(2, v1.hash, """{"member":"a","role_ids":["admin"]}""", rumorId = "id-forged") + val admissible = EditionFold.admissible(listOf(v0, v1, forgedV2), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + + assertContentEquals(listOf(v0.rumorId, v1.rumorId, v2.rumorId), admissible.map { it.rumorId }) + } + + /** When the chain connects, nothing is filtered — the new epoch may legitimately advance. */ + @Test + fun admissiblePassesEverythingThroughWhenTheChainConnects() { + val admissible = EditionFold.admissible(listOf(v2, v3), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + assertContentEquals(listOf(v2.rumorId, v3.rumorId), admissible.map { it.rumorId }) + } + + /** Omitting an entity outright is the cheapest rollback of all (a dropped banlist is an unban). */ + @Test + fun admissibleKeepsAnEntityThatWasOmittedEntirely() { + val otherV0 = edition(0, null, """{"member":"b","role_ids":["mod"]}""", entityId = otherEid, rumorId = "other-v0") + val admissible = EditionFold.admissible(listOf(otherV0), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + + assertContentEquals(listOf(otherV0.rumorId, v2.rumorId), admissible.map { it.rumorId }) + } + + /** No floors at all → the fresh-joiner path, untouched. */ + @Test + fun admissibleIsAPassThroughWithoutFloors() { + val admissible = EditionFold.admissible(listOf(v1), emptyMap()) + assertContentEquals(listOf(v1.rumorId), admissible.map { it.rumorId }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index 00d941ad25..95329c3b98 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -104,6 +104,49 @@ class ConcordInviteClassifyTest { assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16))) } + /** + * Regression: `expires_at` used to be decorative — [ConcordInviteBundle.isExpired] had no + * production caller, so an expired link redeemed forever. Enforcement lives in [classify], + * which every redeeming path (Account.joinConcordViaInvite) funnels through. + */ + @Test + fun expiredBundleDoesNotResolveLive() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val expiresAtMs = 2_000_000L + val invite = + CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + expiresAt = expiresAtMs, + ) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", invite, createdAt = 1L, relays = listOf("wss://relay.example")) + val wraps = listOf(minted.bundleEvent) + + // Before the expiry the very same bundle still opens… + val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1) + assertTrue(live is InviteBundleStatus.Live) + + // …and after it, the join path must refuse it (not Live) while the preview data survives. + val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1) + assertTrue(expired is InviteBundleStatus.Expired) + assertEquals(community.communityIdHex, expired.invite.communityId) + } + + /** No `expires_at` means "never expires" — it must not be read as "expired at epoch 0". */ + @Test + fun bundleWithoutExpiryNeverExpires() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live) + } + @Test fun emptyFetchIsAbsent() { assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt new file mode 100644 index 0000000000..6c0f9aa59c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt @@ -0,0 +1,255 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Stranded recovery: a member left out of a Refounding's recipient set gets no rekey at + * all, so the only way they learn about the new epoch is by re-resolving the invite link + * they joined through and finding a higher-epoch bundle there. + */ +class ConcordStrandedRecoveryTest { + private val communityId = "11".repeat(32) + private val token = ByteArray(16) { it.toByte() } + private val linkSigner = KeyPair().pubKey.toHexKey() + + private val inviteRef = ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token).substringAfter("/invite/") + + private fun entry( + epoch: Long, + ref: String? = inviteRef, + heldRoots: List = emptyList(), + ) = ConcordCommunityListEntry( + id = communityId, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "bb".repeat(32), + rootEpoch = epoch, + heldRoots = heldRoots, + relays = listOf("wss://relay.example"), + name = "Gamers", + addedAt = 1_700_000_000_000L, + inviteRef = ref, + ) + + private fun bundle( + epoch: Long, + root: String = "cc".repeat(32), + id: String = communityId, + ) = CommunityInvite( + communityId = id, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + communityRoot = root, + rootEpoch = epoch, + relays = listOf("wss://relay.example"), + name = "Gamers", + ) + + // ---- merge forward -------------------------------------------------------- + + @Test + fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() { + val prior = HeldRoot(0L, "aa".repeat(32)) + val stranded = entry(epoch = 1, heldRoots = listOf(prior)) + + val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5)) + assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind") + + // adopted the new epoch's access root + assertEquals(5L, merged.rootEpoch) + assertEquals("cc".repeat(32), merged.root) + + // the anchor survives, or the *next* exclusion would be unrecoverable + assertEquals(inviteRef, merged.inviteRef) + + // prior-epoch history we legitimately hold is not lost, and the root we just left + // is added so epoch-1 channels stay derivable + assertEquals(setOf(0L, 1L), merged.heldRoots.map { it.epoch }.toSet()) + assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key }) + assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) }) + + // identity is untouched and we record where we were dropped + assertEquals(communityId, merged.id) + assertEquals(stranded.addedAt, merged.addedAt) + assertEquals(1L, merged.excludedAtEpoch) + } + + @Test + fun sameEpochBundleIsANoOp() { + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5))) + assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5))) + } + + @Test + fun lowerEpochBundleIsANoOp() { + // Epoch-monotonic: a stale bundle must never walk the membership backwards. + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3))) + } + + @Test + fun entryWithoutInviteRefIsInert() { + // Direct invites and legacy entries have no anchor — expected, not an error. + val noAnchor = entry(epoch = 1, ref = null) + assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9))) + assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9))) + } + + @Test + fun bundleForAnotherCommunityIsIgnored() { + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)))) + } + + // ---- the bare `#` anchor form ---------------------------- + + @Test + fun bareFormStripsTheHostSoAnyFrontEndsLinkMatches() { + val amethyst = ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token) + val armada = ConcordInviteLink.buildUrl("https://someother.example", linkSigner, token) + + val bare = ConcordInviteLink.bareForm(amethyst) + assertNotNull(bare) + assertFalse(bare.contains("amethyst.social")) + assertFalse(bare.contains("/invite/")) + assertTrue(bare.startsWith("naddr")) + + // domain-agnostic: the same invite shared through a different front end reduces + // to the identical anchor, which is the whole point of storing the bare form + assertEquals(bare, ConcordInviteLink.bareForm(armada)) + } + + @Test + fun bareFormReParsesBackToTheSamePointerAndToken() { + val bare = ConcordInviteLink.bareForm(ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token))!! + val parsed = ConcordInviteLink.parseUrl(bare) + assertNotNull(parsed, "the stored bare anchor must be re-resolvable, or recovery can never fire") + assertEquals(linkSigner, parsed.linkSignerPubKey) + assertEquals(token.toList(), parsed.fragment.token.toList()) + } + + @Test + fun bareFormOfGarbageIsNull() { + assertNull(ConcordInviteLink.bareForm("https://amethyst.social/invite/nope")) + assertNull(ConcordInviteLink.bareForm("not a link")) + } + + // ---- wire round-trip (Armada interop) ------------------------------------ + + @Test + fun inviteRefAndExcludedAtEpochRoundTripOnTheWire() { + val original = + ConcordCommunityListEntry( + id = communityId, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "cc".repeat(32), + rootEpoch = 5, + heldRoots = listOf(HeldRoot(0L, "aa".repeat(32)), HeldRoot(1L, "bb".repeat(32))), + relays = listOf("wss://relay.example"), + name = "Gamers", + addedAt = 1_700_000_000_000L, + inviteRef = inviteRef, + excludedAtEpoch = 1L, + ) + + val json = ConcordCommunityList.encode(listOf(original)) + + // Armada's field names, verbatim — a mismatch silently breaks interop both ways. + assertTrue(json.contains("\"invite_ref\""), "must serialize as invite_ref: $json") + assertTrue(json.contains("\"excluded_at_epoch\""), "must serialize as excluded_at_epoch: $json") + + val back = ConcordCommunityList.decode(json).single() + assertEquals(inviteRef, back.inviteRef) + assertEquals(1L, back.excludedAtEpoch) + assertEquals(5L, back.rootEpoch) + assertEquals("cc".repeat(32), back.root) + assertEquals(listOf(0L, 1L), back.heldRoots.map { it.epoch }) + } + + @Test + fun decodesArmadaEntryCarryingInviteRef() { + // Shape as Armada's communityList.ts writes it, with invite_ref / excluded_at_epoch + // at the ENTRY level (not inside the JoinMaterial). + val json = + """ + { + "entries": [ + { + "community_id": "$communityId", + "current": { + "community_id": "$communityId", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 4 + }, + "added_at": 1700000000000, + "invite_ref": "$inviteRef", + "excluded_at_epoch": 2 + } + ], + "tombstones": [] + } + """.trimIndent() + + val entry = ConcordCommunityList.decode(json).single() + assertEquals(inviteRef, entry.inviteRef) + assertEquals(2L, entry.excludedAtEpoch) + assertEquals(4L, entry.rootEpoch) + } + + @Test + fun anEntryWithoutInviteRefStillDecodes() { + // Legacy lists (and Armada entries joined by direct invite) carry no invite_ref. + val json = ConcordCommunityList.encode(listOf(entry(epoch = 1, ref = null))) + val back = ConcordCommunityList.decode(json).single() + assertNull(back.inviteRef) + assertNull(back.excludedAtEpoch) + } + + @Test + fun mergeKeepsTheAnchorWhenTheHigherEpochCopyLacksIt() { + // Two devices: one holds the anchor at the old epoch, the other rotated forward + // without it. Dropping the anchor here would disarm recovery permanently. + val anchored = entry(epoch = 1, ref = inviteRef) + val rotatedNoAnchor = entry(epoch = 4, ref = null) + + val merged = ConcordCommunityList.merge(listOf(anchored), listOf(rotatedNoAnchor)).single() + assertEquals(4L, merged.rootEpoch) + assertEquals(inviteRef, merged.inviteRef) + + // and the same regardless of argument order + val other = ConcordCommunityList.merge(listOf(rotatedNoAnchor), listOf(anchored)).single() + assertEquals(4L, other.rootEpoch) + assertEquals(inviteRef, other.inviteRef) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 0dadfcdf1f..3bab42b3e3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -274,4 +274,71 @@ class Nip05Test { assertEquals(result, newResult) } + + // ── parseLenient ────────────────────────────────────────────────── + // parseLenient is used by mention/text-rendering paths and accepts both + // the full `name@domain` NIP-05 form and a bare domain that is synthesized + // as the `_@domain` wildcard form per NIP-05. + + @Test + fun `parseLenient accepts full form like Nip05Id parse`() { + val full = Nip05Id.parseLenient("alice@example.com") + assertNotNull(full) + assertEquals("alice", full.name) + assertEquals("example.com", full.domain) + } + + @Test + fun `parseLenient accepts dot bit full form`() { + val parsed = Nip05Id.parseLenient("m@testls.bit") + assertNotNull(parsed) + assertEquals("m", parsed.name) + assertEquals("testls.bit", parsed.domain) + } + + @Test + fun `parseLenient synthesizes wildcard for bare domain`() { + val bare = Nip05Id.parseLenient("testls.bit") + assertNotNull(bare) + assertEquals("_", bare.name) + assertEquals("testls.bit", bare.domain) + assertEquals(false, bare.hasLocalPart()) + assertEquals("testls.bit", bare.toDisplayValue()) + assertEquals("_@testls.bit", bare.toValue()) + } + + @Test + fun `parseLenient synthesizes wildcard for non-bit bare domain`() { + val bare = Nip05Id.parseLenient("nostr.example.org") + assertNotNull(bare) + assertEquals("_", bare.name) + assertEquals("nostr.example.org", bare.domain) + } + + @Test + fun `parseLenient lowercases input`() { + val parsed = Nip05Id.parseLenient("M@TestLS.BIT") + assertNotNull(parsed) + assertEquals("m", parsed.name) + assertEquals("testls.bit", parsed.domain) + } + + @Test + fun `parseLenient rejects single-label garbage`() { + assertNull(Nip05Id.parseLenient("justaword")) + assertNull(Nip05Id.parseLenient("")) + assertNull(Nip05Id.parseLenient(" ")) + } + + @Test + fun `parseLenient rejects IP literal disguised as a bare domain`() { + assertNull(Nip05Id.parseLenient("192.168.1.1")) + } + + @Test + fun `parseLenient rejects malformed full form`() { + assertNull(Nip05Id.parseLenient("@example.com")) + assertNull(Nip05Id.parseLenient("foo@")) + assertNull(Nip05Id.parseLenient("foo@bar")) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt index f64a4fc7a6..7ee9fdc303 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt @@ -114,6 +114,8 @@ class BunkerRequestProcessorConcurrencyTest { private class GatedAuthorizer( val gateFor: (BunkerRequest) -> CompletableDeferred?, ) : Nip46RequestAuthorizer { + override suspend fun isPaired(clientPubKey: HexKey) = true + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt index fcfb8a711b..3533bb2959 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -128,9 +129,16 @@ class BunkerRequestProcessorTest { private class FakeAuthorizer( val connectDecision: Nip46ConnectDecision, val allow: Boolean, + val paired: Boolean = true, ) : Nip46RequestAuthorizer { var connectCalls = 0 var authorizeCalls = 0 + var isPairedCalls = 0 + + override suspend fun isPaired(clientPubKey: HexKey): Boolean { + isPairedCalls++ + return paired + } override suspend fun onConnect( clientPubKey: HexKey, @@ -157,34 +165,86 @@ class BunkerRequestProcessorTest { ) = BunkerRequestProcessor(signer, { setOf(relay) }, authorizer) @Test - fun getPublicKeyReturnsUserPubKeyWithoutAuthorization() = + fun getPublicKeyReturnsUserPubKeyForAPairedClientWithoutPerOpAuthorization() = runTest { - val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + // Paired but every op denied: the identity read still answers — it needs pairing, not consent. + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false, paired = true) val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestGetPublicKey("1")) assertTrue(res is BunkerResponsePublicKey) assertEquals(userPubKey, res.pubkey) assertEquals("1", res.id) - // public reads are never gated + // gated on pairing only, never on per-op consent assertEquals(0, authorizer.authorizeCalls) + assertEquals(1, authorizer.isPairedCalls) } + /** + * The identity leak: anyone who obtains the `bunker://` URI can encrypt a well-formed request + * without knowing the secret. `get_public_key` must not tell them WHICH account this signer is. + */ @Test - fun pingReturnsPong() = + fun getPublicKeyIsRefusedForAnUnpairedClient() = runTest { - val res = processor().process(clientPubKey, BunkerRequestPing("2")) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true, paired = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestGetPublicKey("1")) + + assertTrue(res is BunkerResponseError, "unpaired get_public_key must error, got $res") + assertEquals(BunkerRequestProcessor.ERROR_NOT_CONNECTED, res.error) + assertEquals("1", res.id) + assertNull(res.result, "the error reply must carry no identity result") + } + + /** + * `ping` stays open on purpose: it only confirms a signer is alive at a pubkey the caller already + * has (it is in the URI they hold) and leaks no identity, so gating it would risk breaking a + * legitimate pre-connect liveness check for no privacy gain. Pinned so the choice is deliberate. + */ + @Test + fun pingReturnsPongEvenForAnUnpairedClient() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false, paired = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestPing("2")) assertTrue(res is BunkerResponsePong) assertEquals("2", res.id) + assertEquals(0, authorizer.isPairedCalls, "ping is deliberately not pairing-gated") } @Test - fun getRelaysReturnsConfiguredRelays() = + fun getRelaysReturnsConfiguredRelaysForAPairedClient() = runTest { val res = processor().process(clientPubKey, BunkerRequestGetRelays("3")) assertTrue(res is BunkerResponseGetRelays) assertTrue(res.relays.containsKey(relay.url)) } + /** `get_relays` additionally yields the user's inbox relay set — same pairing gate. */ + @Test + fun getRelaysIsRefusedForAnUnpairedClient() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true, paired = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestGetRelays("3")) + + assertTrue(res is BunkerResponseError, "unpaired get_relays must error, got $res") + assertEquals(BunkerRequestProcessor.ERROR_NOT_CONNECTED, res.error) + assertNull(res.result, "the error reply must carry no relay set") + } + + /** + * The regression that would break ALL pairing: `connect` is how a client becomes paired, so it + * must stay reachable to a client that is not paired yet. + */ + @Test + fun connectStillWorksForAnUnpairedClient() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("s3cr3t"), allow = false, paired = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "9", remoteKey = userPubKey, secret = "s3cr3t")) + + assertEquals(1, authorizer.connectCalls) + assertTrue(res !is BunkerResponseError, "connect must not be pairing-gated, got $res") + assertEquals("s3cr3t", res.result) + } + @Test fun connectAcceptEchoesSecret() = runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 98a7dcff66..9f3ac113f1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -147,6 +147,8 @@ class NostrConnectSignerServiceTest { private class AllowAuthorizer : Nip46RequestAuthorizer { var logoutCalls = 0 + override suspend fun isPaired(clientPubKey: HexKey) = true + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt index 391fac4952..c949b0fa1a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt @@ -31,6 +31,39 @@ class PodcastValueShareTest { fee: Boolean? = null, ) = PodcastValueRecipient(name = name, type = PodcastValue.TYPE_NODE, address = "node-$name", split = split, fee = fee) + @Test + fun `a hostile fee split can never pay out more than the total`() { + // The value block comes verbatim from a kind-30054 episode event, which anyone can publish, + // and `split` is an unvalidated Int. A fee recipient is paid `total * split / 100`, so a + // split of 1000 would bill the user 10x what they chose — every minute, for a streaming + // payment whose on-screen running total shows only the intended amount. + val value = + PodcastValue( + recipients = listOf(node("attacker", 1000, fee = true), node("host", 100)), + ) + + val shares = value.computeShares(1_000_000L) + val paid = shares.sumOf { it.amountMilliSats } + + assertTrue(paid <= 1_000_000L, "paid $paid millisats for a 1,000,000 millisat zap") + } + + @Test + fun `fee splits summing over 100 percent cannot exceed the total either`() { + val value = + PodcastValue( + recipients = + listOf( + node("feeA", 60, fee = true), + node("feeB", 60, fee = true), + node("host", 100), + ), + ) + + val paid = value.computeShares(1_000_000L).sumOf { it.amountMilliSats } + assertTrue(paid <= 1_000_000L, "paid $paid millisats for a 1,000,000 millisat zap") + } + @Test fun `weighted split with no fees divides by relative weight`() { val value = diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt new file mode 100644 index 0000000000..b0324f0e5c --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt @@ -0,0 +1,317 @@ +/* + * 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.nip60Cashu.mintApi + +/** + * Thrown when a mint URL is refused before any request is issued. The message is + * user-facing: it is surfaced verbatim in the "could not redeem" dialog. + */ +class MintUrlException( + message: String, +) : RuntimeException(message) + +/** + * Gatekeeper for the `mintUrl` a [MintHttpClient] is about to talk to. + * + * A Cashu token carries its own mint URL (`token.mint`) and that URL is attacker + * controlled: anybody can paste or post a token. Redeeming it makes the device + * issue HTTP requests to whatever it says, which is an SSRF primitive against + * anything the phone can reach (`http://127.0.0.1:`, the home router at + * `192.168.1.1`, `169.254.169.254` cloud metadata) plus an IP-address discloser + * for whoever controls the URL. + * + * The rule: + * - `https://` to a public host is allowed. + * - `http://` is allowed **only** to a `.onion` host. Amethyst supports Tor and + * onion mints are legitimate; a blanket "https only" would break them. Onion + * names are self-authenticating and never resolve to a local address, so the + * private-range checks below don't apply to them. + * - Any other scheme (`file:`, `ftp:`, `data:`, …) is refused. + * - Loopback, RFC1918 private, link-local, CGNAT, unique-local, unspecified, + * multicast and broadcast addresses are refused, including via the usual + * spellings: IPv4-mapped/compatible IPv6 (`::ffff:127.0.0.1`), `inet_aton` + * decimal/octal/hex forms (`http://2130706433`, `http://0177.0.0.1`), + * `http://user@127.0.0.1`, and a trailing-dot hostname (`localhost.`). + * + * [userConfigured] switches the host checks off (the scheme check still runs). + * Pass it only for a mint the user deliberately added — a mint already in their + * NIP-60 wallet, or one they typed into the CLI. A self-hosted mint on the LAN + * is a legitimate setup; the threat modelled here is a *pasted, untrusted* token + * pointing at an internal address. + * + * NOTE: this is a pre-resolution check on the literal host, so it does not stop + * DNS rebinding (a public name that resolves to 127.0.0.1). That is deliberately + * out of scope — closing it needs a resolve-then-pin socket factory, not a URL + * check. + */ +object CashuMintUrlValidator { + /** + * Validates [mintUrl] and returns the base URL to build request paths from + * (trailing slashes stripped, matching the previous [MintHttpClient] behaviour). + * + * @throws MintUrlException with a user-facing reason when the URL is refused. + */ + fun validatedBaseUrl( + mintUrl: String, + userConfigured: Boolean = false, + ): String { + val url = mintUrl.trim() + if (url.isEmpty()) throw MintUrlException("The token does not name a mint.") + + val schemeEnd = url.indexOf("://") + if (schemeEnd <= 0) throw MintUrlException("The mint address \"$url\" is not an https address.") + + val scheme = url.substring(0, schemeEnd).lowercase() + if (scheme != "https" && scheme != "http") { + throw MintUrlException("The mint address uses the unsupported \"$scheme:\" scheme. Only https is allowed.") + } + + val host = hostOf(url.substring(schemeEnd + 3)) + if (host.isEmpty()) throw MintUrlException("The mint address \"$url\" has no host.") + + val isOnion = host == "onion" || host.endsWith(".onion") + + if (scheme == "http" && !isOnion && !userConfigured) { + throw MintUrlException("The mint address \"$url\" is not encrypted (http). Only https, or an .onion address over Tor, is allowed.") + } + + if (!userConfigured && !isOnion && isPrivateHost(host)) { + throw MintUrlException("The mint address \"$url\" points at a private or local address. Amethyst will not contact it.") + } + + return url.trimEnd('/') + } + + /** + * Extracts the host from the part of the URL that follows `scheme://`: + * drops the path/query/fragment, drops any `user:pass@` prefix (so + * `http://mint.example.com@127.0.0.1/` is correctly read as `127.0.0.1`), + * drops the port, unwraps a `[…]` IPv6 literal, and normalises case plus + * the FQDN trailing dot. + */ + private fun hostOf(afterScheme: String): String { + var rest = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#') + // userinfo goes up to the LAST '@' — an attacker can embed one in the password. + val at = rest.lastIndexOf('@') + if (at >= 0) rest = rest.substring(at + 1) + + val host = + if (rest.startsWith("[")) { + rest.substringAfter('[').substringBefore(']') + } else { + rest.substringBefore(':') + } + + return host.lowercase().trimEnd('.') + } + + /** + * True when [host] is a literal address inside a range we must never reach + * from an untrusted token. Names that are not IP literals return false: the + * check is pre-resolution (see the DNS-rebinding note on the object), except + * for `localhost`, which is a name but always local. + */ + fun isPrivateHost(host: String): Boolean { + if (host == "localhost" || host.endsWith(".localhost")) return true + + if (host.contains(':')) { + val v6 = parseIpv6(host) ?: return false + return isPrivateIpv6(v6) + } + + val v4 = parseIpv4(host) ?: return false + return isPrivateIpv4(v4) + } + + // ------------------------------------------------------------------ + // IPv4 + // ------------------------------------------------------------------ + + /** + * `inet_aton`-style parse: accepts 1..4 parts, each decimal, octal (`0…`) or + * hex (`0x…`), because that is what a C resolver — and therefore a lot of the + * stack below us — will happily accept. Returns the address as an unsigned + * 32-bit value in a [Long], or null when [host] is not an IPv4 literal at all. + */ + fun parseIpv4(host: String): Long? { + if (host.isEmpty()) return null + val parts = host.split('.') + if (parts.size > 4) return null + + val values = ArrayList(parts.size) + for (part in parts) { + values.add(parseIpv4Part(part) ?: return null) + } + + // The last part absorbs every byte the earlier parts didn't name: + // "127.1" is 127.0.0.1, "2130706433" is 127.0.0.1. + val n = values.size + var result = 0L + for (i in 0 until n - 1) { + val v = values[i] + if (v > 255L) return null + result = result or (v shl (8 * (3 - i))) + } + val tailMax = (1L shl (8 * (4 - (n - 1)))) - 1L + val tail = values[n - 1] + if (tail > tailMax) return null + return result or tail + } + + private fun parseIpv4Part(part: String): Long? { + if (part.isEmpty()) return null + return when { + part.startsWith("0x") || part.startsWith("0X") -> { + val digits = part.substring(2) + if (digits.isEmpty() || !digits.all { it.isHexDigit() }) return null + digits.toLongOrNull(16) + } + part.length > 1 && part[0] == '0' -> { + val digits = part.substring(1) + if (!digits.all { it in '0'..'7' }) return null + digits.toLongOrNull(8) + } + else -> { + if (!part.all { it in '0'..'9' }) return null + part.toLongOrNull() + } + }?.takeIf { it >= 0 && it <= 0xFFFFFFFFL } + } + + private fun isPrivateIpv4(addr: Long): Boolean { + val a = ((addr shr 24) and 0xFF).toInt() + val b = ((addr shr 16) and 0xFF).toInt() + return when { + a == 0 -> true // 0.0.0.0/8 — "this network", includes 0.0.0.0 + a == 10 -> true // RFC1918 + a == 127 -> true // loopback + a == 169 && b == 254 -> true // link-local + a == 172 && b in 16..31 -> true // RFC1918 + a == 192 && b == 168 -> true // RFC1918 + a == 100 && b in 64..127 -> true // CGNAT, RFC6598 + a == 192 && b == 0 -> true // 192.0.0.0/24 IETF protocol assignments + a in 224..255 -> true // multicast + reserved + 255.255.255.255 + else -> false + } + } + + // ------------------------------------------------------------------ + // IPv6 + // ------------------------------------------------------------------ + + /** Parses an IPv6 literal (with optional `::` run and optional trailing IPv4) into 16 bytes. */ + fun parseIpv6(literal: String): ByteArray? { + // A zone id (fe80::1%wlan0) doesn't change which range we're in. + val text = literal.substringBefore('%') + if (text.isEmpty()) return null + + val out = ByteArray(16) + val doubleColon = text.indexOf("::") + if (text.indexOf("::", doubleColon + 1) >= 0) return null // more than one "::" + + val headText = if (doubleColon >= 0) text.substring(0, doubleColon) else text + val tailText = if (doubleColon >= 0) text.substring(doubleColon + 2) else "" + + val head = splitGroups(headText) ?: return null + val tail = splitGroups(tailText) ?: return null + + // A trailing dotted-quad ("::ffff:127.0.0.1") counts as the last two groups. + val headBytes = groupsToBytes(head) ?: return null + val tailBytes = groupsToBytes(tail) ?: return null + + if (doubleColon < 0) { + if (headBytes.size != 16) return null + return headBytes + } + if (headBytes.size + tailBytes.size > 14) return null // "::" must stand for >= 1 group + + headBytes.copyInto(out, 0) + tailBytes.copyInto(out, 16 - tailBytes.size) + return out + } + + private fun splitGroups(text: String): List? { + if (text.isEmpty()) return emptyList() + val groups = text.split(':') + if (groups.any { it.isEmpty() }) return null + return groups + } + + private fun groupsToBytes(groups: List): ByteArray? { + val bytes = ArrayList(16) + for ((index, group) in groups.withIndex()) { + if (group.contains('.')) { + // Only legal as the final element, and only in strict dotted-quad form. + if (index != groups.size - 1) return null + val quad = group.split('.') + if (quad.size != 4) return null + for (q in quad) { + if (q.isEmpty() || q.length > 3 || !q.all { it in '0'..'9' }) return null + val v = q.toInt() + if (v > 255) return null + bytes.add(v.toByte()) + } + } else { + if (group.length > 4 || !group.all { it.isHexDigit() }) return null + val v = group.toInt(16) + bytes.add(((v shr 8) and 0xFF).toByte()) + bytes.add((v and 0xFF).toByte()) + } + } + if (bytes.size > 16) return null + return bytes.toByteArray() + } + + private fun isPrivateIpv6(addr: ByteArray): Boolean { + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) carry an + // IPv4 address; judge them by the IPv4 rules or ::ffff:127.0.0.1 walks in. + val first10Zero = (0 until 10).all { addr[it].toInt() == 0 } + if (first10Zero) { + val ffff = (addr[10].toInt() and 0xFF) == 0xFF && (addr[11].toInt() and 0xFF) == 0xFF + val compat = addr[10].toInt() == 0 && addr[11].toInt() == 0 + if (ffff || compat) { + val v4 = + ((addr[12].toLong() and 0xFF) shl 24) or + ((addr[13].toLong() and 0xFF) shl 16) or + ((addr[14].toLong() and 0xFF) shl 8) or + (addr[15].toLong() and 0xFF) + // ::0.0.0.0 / ::1 fall out of the IPv4 rules too (0/8 is blocked). + if (compat && v4 <= 1L) return true + return isPrivateIpv4(v4) + } + } + + val b0 = addr[0].toInt() and 0xFF + val b1 = addr[1].toInt() and 0xFF + + // :: (unspecified) and ::1 (loopback) + if (addr.all { it.toInt() == 0 }) return true + if ((0..14).all { addr[it].toInt() == 0 } && addr[15].toInt() == 1) return true + + if (b0 == 0xFF) return true // ff00::/8 multicast + if (b0 == 0xFE && (b1 and 0xC0) == 0x80) return true // fe80::/10 link-local + if ((b0 and 0xFE) == 0xFC) return true // fc00::/7 unique-local + + return false + } + + private fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt index 35b39110cb..c6cdd37c4e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt @@ -57,12 +57,22 @@ class MintProtocolException( * * Each instance is bound to a single mint URL (e.g. `https://mint.example.com`). * Trailing slashes are stripped on construction. + * + * The URL is validated by [CashuMintUrlValidator] **on construction**, before any + * request can be issued, because `token.mint` on a pasted Cashu token is fully + * attacker controlled (SSRF + IP disclosure). Pass [userConfigured] = true only + * when the URL came from somewhere the user deliberately chose it — a mint in + * their own NIP-60 wallet, or one they typed on the CLI — which relaxes the + * host checks (a self-hosted LAN mint is legitimate) but never the scheme check. + * + * @throws MintUrlException when the mint URL is refused. */ class MintHttpClient( mintUrl: String, + userConfigured: Boolean = false, private val okHttpClient: (String) -> OkHttpClient, ) { - private val baseUrl: String = mintUrl.trimEnd('/') + private val baseUrl: String = CashuMintUrlValidator.validatedBaseUrl(mintUrl, userConfigured) // Mint /v1/info changes infrequently (mint name, icon, supported // NUTs, motd). Cache it for [INFO_CACHE_TTL_MS] so the Verify diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt new file mode 100644 index 0000000000..7398dd1d39 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt @@ -0,0 +1,223 @@ +/* + * 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.nip60Cashu.mintApi + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * SSRF guard for `token.mint`. Every URL below arrives from a pasted/posted + * Cashu token, so it is attacker controlled: the only ones we may ever contact + * are https to a public host, and http to a .onion. + */ +class CashuMintUrlValidatorTest { + private fun allow(url: String) { + val result = CashuMintUrlValidator.validatedBaseUrl(url) + assertEquals(url.trimEnd('/'), result) + } + + private fun reject(url: String) { + assertThrows("expected $url to be refused", MintUrlException::class.java) { + CashuMintUrlValidator.validatedBaseUrl(url) + } + } + + // ------------------------------------------------------------------ + // Allowed + // ------------------------------------------------------------------ + + @Test + fun httpsPublicHostIsAllowed() { + allow("https://mint.minibits.cash/Bitcoin") + allow("https://8333.space:3338") + allow("https://mint.example.com/") + allow("https://8.8.8.8") + } + + @Test + fun onionOverHttpIsAllowed() { + allow("http://cashuxyzabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrs.onion") + allow("http://mint.somewhere.onion:3338/v1") + } + + @Test + fun onionOverHttpsIsAllowed() { + allow("https://cashuxyzabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrs.onion") + } + + // ------------------------------------------------------------------ + // Rejected — scheme + // ------------------------------------------------------------------ + + @Test + fun httpToPublicHostIsRejected() { + reject("http://mint.example.com") + } + + @Test + fun nonHttpSchemesAreRejected() { + reject("file:///etc/passwd") + reject("ftp://mint.example.com") + reject("data://text/plain,hello") + reject("content://com.android.provider/x") + reject("mint.example.com") + reject("") + } + + // ------------------------------------------------------------------ + // Rejected — private / local destinations + // ------------------------------------------------------------------ + + @Test + fun loopbackIsRejected() { + reject("http://127.0.0.1") + reject("https://127.0.0.1:3338") + reject("http://127.0.0.1:8080/v1/info") + reject("https://localhost:3338") + reject("http://[::1]") + reject("https://[::1]:3338") + } + + @Test + fun privateRangesAreRejected() { + reject("http://192.168.1.5") + reject("https://192.168.1.5") + reject("https://10.0.0.7:3338") + reject("https://172.16.4.4") + reject("https://172.31.255.255") + } + + @Test + fun linkLocalAndMetadataEndpointsAreRejected() { + reject("http://169.254.169.254") + reject("https://169.254.169.254/latest/meta-data/") + reject("https://[fe80::1]") + } + + @Test + fun uniqueLocalAndUnspecifiedAreRejected() { + reject("https://[fc00::1]") + reject("https://[fd12:3456:789a::1]") + reject("https://0.0.0.0") + reject("https://[::]") + } + + @Test + fun ipv4MappedIpv6BypassIsRejected() { + reject("https://[::ffff:127.0.0.1]") + reject("https://[::ffff:192.168.1.5]") + reject("https://[::ffff:7f00:1]") + reject("https://[::127.0.0.1]") + } + + @Test + fun inetAtonSpellingsOfLoopbackAreRejected() { + reject("https://2130706433") // decimal 127.0.0.1 + reject("https://0177.0.0.1") // octal + reject("https://0x7f.0.0.1") // hex + reject("https://0x7f000001") + reject("https://127.1") // 2-part form + } + + @Test + fun trailingDotHostnameIsRejected() { + reject("https://localhost.") + reject("https://127.0.0.1.") + } + + @Test + fun userInfoCannotDisguiseTheHost() { + reject("https://mint.example.com@127.0.0.1/v1/info") + reject("https://mint.example.com:pass@192.168.1.5/") + } + + // ------------------------------------------------------------------ + // The user's own wallet mint exception + // ------------------------------------------------------------------ + + @Test + fun privateHostIsAllowedWhenTheUserConfiguredTheMint() { + assertEquals( + "http://192.168.1.5:3338", + CashuMintUrlValidator.validatedBaseUrl("http://192.168.1.5:3338", userConfigured = true), + ) + assertEquals( + "https://127.0.0.1:3338", + CashuMintUrlValidator.validatedBaseUrl("https://127.0.0.1:3338", userConfigured = true), + ) + } + + @Test + fun userConfiguredStillRefusesNonHttpSchemes() { + assertThrows(MintUrlException::class.java) { + CashuMintUrlValidator.validatedBaseUrl("file:///etc/passwd", userConfigured = true) + } + } + + // ------------------------------------------------------------------ + // The client refuses at construction, before any request is issued + // ------------------------------------------------------------------ + + @Test + fun mintHttpClientRefusesAtConstruction() { + assertThrows(MintUrlException::class.java) { + MintHttpClient("http://127.0.0.1:3338") { throw AssertionError("must not build an http client") } + } + } + + @Test + fun mintHttpClientAcceptsPublicHttps() { + MintHttpClient("https://mint.example.com/") { throw AssertionError("no request expected") } + } + + // ------------------------------------------------------------------ + // Parser units — kept so a future "modernization" of the IP parsing + // can't silently reopen a bypass. + // ------------------------------------------------------------------ + + @Test + fun ipv4ParserHandlesInetAtonForms() { + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("127.0.0.1")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("2130706433")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("0177.0.0.1")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("0x7f000001")) + assertEquals(0x7F000001L, CashuMintUrlValidator.parseIpv4("127.1")) + assertEquals(null, CashuMintUrlValidator.parseIpv4("mint.example.com")) + assertEquals(null, CashuMintUrlValidator.parseIpv4("256.0.0.1")) + } + + @Test + fun ipv6ParserHandlesCompressionAndEmbeddedIpv4() { + assertTrue(CashuMintUrlValidator.parseIpv6("::1")!!.let { it[15].toInt() == 1 && it.take(15).all { b -> b.toInt() == 0 } }) + assertEquals(16, CashuMintUrlValidator.parseIpv6("2001:db8::1")!!.size) + assertEquals(16, CashuMintUrlValidator.parseIpv6("::ffff:127.0.0.1")!!.size) + assertEquals(null, CashuMintUrlValidator.parseIpv6("::1::2")) + assertEquals(null, CashuMintUrlValidator.parseIpv6("gggg::1")) + } + + @Test + fun publicIpv6IsAllowed() { + allow("https://[2001:db8::1]") + allow("https://[2606:4700:4700::1111]:3338") + } +}