From 6ac8e475df7beb57fd9ce5e62189b61d2c771de3 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 10 Jun 2026 07:14:50 +1000 Subject: [PATCH 01/43] feat(nip05): add Nip05Id.parseLenient for mention/text rendering Add a small companion helper that lets callers parse either: - a full NIP-05 identifier (`name@domain.tld`), same as Nip05Id.parse; or - a bare domain (`domain.tld`), synthesized as the wildcard form `_@domain.tld` per NIP-05. This is the building block used by upcoming mention parsing in note compose (NewMessageTagger) and note rendering (RichTextParser) so that typing `@m@testls.bit` or `@testls.bit` in a note tags the user correctly, and a received note that contains `m@testls.bit` becomes a clickable user link instead of falling into the email/mailto bucket. The helper is intentionally split out from the strict `parse` (unchanged) so its lenient behaviour (bare-domain synthesis) cannot sneak into NIP-05 verification flows that need the strict form. --- .../quartz/nip05DnsIdentifiers/Nip05Id.kt | 20 ++++++ .../quartz/nip05DnsIdentifiers/Nip05Test.kt | 67 +++++++++++++++++++ 2 files changed, 87 insertions(+) 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/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")) + } } From a1b7cad85c0993d75718ae77636b1bf4e344c263 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 12 Jun 2026 09:42:20 +1000 Subject: [PATCH 02/43] =?UTF-8?q?feat(compose):=20wire=20NIP-05=20popover?= =?UTF-8?q?=20mentions=20to=20nostr:nprofile1=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the author types a NIP-05 mention (full `m@testls.bit` form or bare `testls.bit` domain), the existing user-suggestion popover already resolves it asynchronously on Dispatchers.IO via Nip05Client (Namecoin for .bit, regular .well-known/nostr.json for everything else). On pick, however, the inserted token was `@npub1…`, losing the relay hints already harvested during resolution. Insert `nostr:${user.toNProfile()} ` for NIP-05-shaped picks so: * the send-time NewMessageTagger parses the bech32 inline through its existing `nprofile1` branch — no extra main-thread I/O, no per-word network round-trips that could compound into 30s+ stalls on dead servers; * relay hints harvested by nip05ResolutionFlow ride along in the nprofile; * the email-vs-mention call is the author's: typing `vitor@vitorpamplona.com` surfaces the suggestion but only commits as a mention when the author taps the row. Just typing the address sends as plain text. Non-NIP-05 picks (search by name, typed npub/nprofile, hex pubkey) keep the existing `@npub1…` insertion form. Also collapses the open-coded `Nip05Id("_", prefix.lowercase())` bare-domain synthesis onto Nip05Id.parseLenient so there's one place doing that conversion. Addresses review feedback on #3165: avoids the extra parser pass and the inline send-time resolve loop in NewMessageTagger by routing all NIP-05 mentions through the popover the author already uses. --- .../userSuggestions/UserSuggestionState.kt | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) 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()} " + } + } } From dbf15a2146b32c927461e7d6cad2659d17d10749 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:56:58 -0400 Subject: [PATCH 03/43] fix(napplet): scope applet grants per app and per account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every napplet/web-app grant was keyed by applet coordinate alone (`:`), which carries no account. The stores and ledgers are process-wide singletons shared by all accounts, so grants leaked in two directions: - **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key` ("sign:1"), and the check ran *before* the per-app ledger lookup. One app's "Allow for this session" therefore authorized that op for every other applet and browser origin, silently, for the broker's lifetime. - **Across accounts.** A grant made under one npub authorized the same applet under every other npub on the device. For a user keeping a pseudonymous account separate from a real one, an app authorized by one could sign as the other with no prompt — defeating the point of separate accounts. NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer` namespaces by account (`nip46::`) and keys session grants by `(coordinate, op)`. Its comment even claims it "mirrors the napplet broker's sessionAllows" — the mirror was backwards. This adopts the same pattern on the napplet side: - `sessionAllows` and `NappletPermissionLedger.session` are keyed by account + coordinate + op. - Napplet storage and the capability store namespace keys by account, read at call time so a switch moves reads/writes with no rebuild. - The signer ledger is deliberately NOT account-scoped at the store: it is shared with NIP-46, whose sessions run for a specific account rather than the active one, so scoping it there would break a background bunker. The napplet path namespaces its own coordinate instead. Also here, found while scoping: - `identity.watch` consulted only the manifest declaration and never the ledger, bypassing a standing DENY — it short-circuits before `NappletBroker.handle`, where the "a standing denial always wins" rule lives. It now applies that rule itself. - `DataStoreNappletStorage.keys()` filtered on a space separator while keys are written with NUL, so it silently matched nothing and always returned an empty list. Existing grants live under the old un-namespaced keys and are not migrated: users are re-prompted once. Migrating would attribute grants made under the broken model to whichever account is active, preserving the bug. Regression tests cover both leak directions. Co-Authored-By: Claude Opus 4.8 --- .../com/vitorpamplona/amethyst/AppModules.kt | 14 +++- .../DataStoreNappletPermissionStore.kt | 27 ++++-- .../napplet/DataStoreNappletStorage.kt | 23 +++-- .../amethyst/napplet/NappletBrokerService.kt | 6 +- .../napplets/ConnectedAppDetailScreen.kt | 2 +- .../loggedIn/napplets/ConnectedAppsScreen.kt | 2 +- .../amethyst/commons/napplet/NappletBroker.kt | 83 ++++++++++++++----- .../permissions/NappletPermissionLedger.kt | 22 +++-- .../commons/napplet/NappletBrokerTest.kt | 56 ++++++++++++- .../NappletPermissionLedgerTest.kt | 20 +++++ .../commons/napplet/NappletRequestRouter.kt | 13 ++- 11 files changed, 219 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 957182ed46..b90881636a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -694,8 +694,20 @@ 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) } + + // 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/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..0367a3c9c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -79,14 +79,14 @@ 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) } + private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore, Amethyst.instance.nappletAccountScope) } // 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)) } 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..d7abf620c2 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 @@ -117,7 +117,7 @@ fun ConnectedAppDetailScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) } + val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore, Amethyst.instance.nappletAccountScope) } val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } val untitled = stringResource(CommonsR.string.napplet_untitled) 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..caa36cc5ae 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 = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore, Amethyst.instance.nappletAccountScope) } val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } var items by remember { mutableStateOf?>(null) } 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..43bb40820d 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,45 @@ 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 = "napplet:${signer.pubKey}:${identity.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. + */ + suspend fun revokeSessionGrants(coordinate: String) { + signerConsentLock.withLock { + sessionAllows.removeAll { it.startsWith("$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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 6cc8c16213..35bfd92705 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 @@ -293,13 +293,64 @@ 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 } } + @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 +384,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..abf7df7c20 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,26 @@ 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 unknownGrantDefaultsToAsk() = runTest { 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 From 389b460f7ae4c67c8b0614ae8253890bd0eea53f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:57:12 -0400 Subject: [PATCH 04/43] fix(napplet): disclose what connecting to an app pre-grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting the "Connect to Nostr" dialog bulk-grants every declared non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE, RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The dialog never showed that: `buildConnectInfo` did not receive `declared` at all, so the user approved a set on the strength of a title and an icon. `SignerConnectInfo.requestedPermissions` already existed for exactly this ("shown so the user gives INFORMED consent before those ops are pre-granted") and is populated by the NIP-46 nostrconnect path. The napplet path simply never filled it in. This threads `declared` through `NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities that actually get pre-granted — SHELL/THEME never prompt and VALUE is per-use, so listing those would overstate what accepting hands over. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/napplet/NostrSignerOpLabels.kt | 24 +++++++++++++++++-- .../gateways/AccountNappletGateways.kt | 8 ++++--- .../signers/NostrSignerConsentPrompt.kt | 11 ++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) 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..b7fab80348 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -24,6 +24,7 @@ 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 @@ -105,10 +106,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 +131,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/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 } // --------------------------------------------------------------------------- From 5bce87d76e2c338674bec8bb9d6c354df2de7880 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:57:28 -0400 Subject: [PATCH 05/43] fix(napplet): make the consent dialog say what a signature really does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog rendered `kind` plus a 160-char content preview and nothing else. For the kinds whose payload lives entirely in the TAGS, that is technically true and practically useless — the user saw "publish an event of kind 3" while approving a replacement of their whole social graph. kind 10002 redirects every future read and write to attacker relays; kind 5 deletes notes. Now, for a replaceable list, it diffs the proposed tags against the copy already cached on the account and reports what actually changes, rather than a raw total that hides the dangerous case (a list that silently drops 130 follows). A single-account edit — by far the common one — names and pictures that account, so the user can recognize who it is at a glance. Republishing an identical list says so plainly instead of raising a false alarm, and a missing baseline falls back to the total and admits it could not compare. Mute lists diff people only, so the string points at "Show Event" for muted words and hashtags. Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which already had one; the napplet dialog had no way to inspect the full event. Also fixes an amountless-invoice display bug in the same family: `LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for a BOLT11 with no amount, so both this dialog and the Blossom pay dialog affirmatively rendered "0 sats" — telling the user a payment was free when the amount is in fact unspecified and chosen by the payee. Co-Authored-By: Claude Opus 4.8 --- .../napplet/NappletConsentActivity.kt | 82 ++++++- .../napplet/NappletConsentCoordinator.kt | 20 ++ .../amethyst/napplet/NappletConsentSummary.kt | 204 +++++++++++++++++- .../uploads/blossom/BlossomPaymentHandler.kt | 9 +- amethyst/src/main/res/values/strings.xml | 56 +++++ 5 files changed, 357 insertions(+), 14 deletions(-) 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/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 1de97b9753..58d1cb0891 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 @@ -46,10 +46,15 @@ object BlossomPaymentHandler { 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 } } /** diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bc61271482..11cb80480c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -841,6 +841,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 +851,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 From 095b1569537623ebbc4935d734172e9565b216f6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:58:47 -0400 Subject: [PATCH 06/43] fix(napplet): give each account its own WebView storage jar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedded web content — browser tabs, napplets, nSites — runs in WebViews in the `:napplet` process, and their cookies, localStorage, IndexedDB and service workers were SHARED across every Nostr account on the device. Nothing in the repo ever cleared them: no `CookieManager`, no `WebStorage` call anywhere. So a web app stayed logged in as the previous account after a switch, and a Nostr web client's localStorage — which routinely holds decrypted DMs, drafts and follow caches — was readable by whichever account came next. For a user keeping a pseudonymous npub apart from a real one, the app could correlate the two itself. Uses the androidx.webkit multi-profile API (already a dependency) to give each account its own profile: cookies, storage, geolocation grants and service workers are all partitioned per `Profile`. Switching accounts moves to that account's jar and switching back restores the session intact — isolation rather than deletion, so nothing is lost. The sandbox never learns which account it is serving. The main process derives an opaque, domain-separated SHA-256 of the account pubkey, truncated to 32 hex chars, and passes only that; `:napplet` validates the shape before use, so a compromised sandbox cannot mint a name for another account's jar. Both re-arm paths read the current profile at send time, so a re-created session can never resurrect the previous account's jar. Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to lossy-but-safe: cookies and web storage are wiped when the account behind the WebViews changes, rather than silently shared. Known gap, documented at the logout hook: a removed account's profile is not deleted. It cannot be done from the main process — WebView profiles live in the `:napplet` data directory, and booting WebView here would collide on it — so it needs a broker message that has the sandbox call `ProfileStore.deleteProfile`, and that must refuse a profile still bound to a live WebView. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/favorites/FavoriteAppLauncher.kt | 18 ++- .../amethyst/napplet/NappletLauncher.kt | 3 + .../napplet/NappletWebViewProfiles.kt | 63 ++++++++++ .../ui/screen/AccountSessionManager.kt | 8 ++ .../browser/EmbeddedWebAppController.kt | 64 +++++++++- .../napplethost/NappletBrowserActivity.kt | 7 ++ .../napplethost/NappletBrowserContract.kt | 8 ++ .../napplethost/NappletBrowserService.kt | 7 ++ .../napplethost/NappletHostActivity.kt | 8 ++ .../napplethost/NappletHostContract.kt | 11 ++ .../napplethost/NappletHostService.kt | 7 ++ .../napplethost/NappletWebViewProfile.kt | 112 ++++++++++++++++++ 12 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletWebViewProfiles.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletWebViewProfile.kt 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/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index d7e6d15f57..c6da22a3ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -156,6 +156,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/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/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/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/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index d7deb5b6f3..ff389271d7 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() @@ -766,6 +769,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 +779,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..447499bbd8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -87,6 +87,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 @@ -159,6 +161,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 +262,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) 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) } + } +} From 7f135a12ab5c955a3620fdaf828f8dc224553fe8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:59:04 -0400 Subject: [PATCH 07/43] fix(embed): rebuild pinned tabs when the account changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WebView's storage profile is fixed at construction, so per-account jars mean each pinned tab has to be rebuilt on an account switch. Nothing did that, and the result was a tab that went permanently black: the logged-in subtree is wrapped in `key(account.pubKey)`, so a switch disposes and rebuilds it, which closes each sandbox session — but `EmbeddedTabHost` is process-scoped, so the warm controllers survived. The rebuilt layer then re-attached fresh views to controllers whose adapter had already been consumed, and a `SandboxedSdkView` with no adapter paints only its background, forever. Three fixes: - Tabs are torn down and re-armed against the new account's profile when it changes, including tabs that were never opened, so none survives bound to the previous account's jar. - `attachView` now recovers instead of silently doing nothing when a second view attaches after the adapter was spent, minting a FRESH session id — reusing the id let the disposed view's late `close()` reap the replacement. - The re-warm kickoff moved off `LaunchedEffect`. It dispatches through the composition's scope, and an account switch floods the main thread: measured on a slow device, the sweep started 943-1157 ms after the teardown, and the account watcher itself fired 3-4 s late. Running it synchronously in the same apply phase drops that to 10-23 ms. The suspending work stays in a coroutine; only the kickoff is immediate. Also gates the load overlay on the tab id rather than a live controller, so a tab that is re-arming shows the existing spinner instead of a bare surface — previously the one moment it most needed a cover was the one moment it had none. Verified on device across both switch directions, and with profile detection force-disabled to confirm the recovery path alone prevents the black. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/ui/navigation/AppNavigation.kt | 5 + .../screen/loggedIn/browser/WebAppScreen.kt | 2 +- .../embed/EmbeddedTabAccountWatcher.kt | 61 +++++++++ .../screen/loggedIn/embed/EmbeddedTabHost.kt | 57 ++++++-- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 23 +++- .../embed/EmbeddedTabPreloadSweeper.kt | 128 ++++++++++++++++++ .../loggedIn/embed/EmbeddedTabPreloader.kt | 70 ++++------ .../loggedIn/embed/EmbeddedTabThemeWatcher.kt | 2 +- .../favorites/EmbeddedNostrAppController.kt | 66 ++++++++- .../loggedIn/favorites/NostrAppScreen.kt | 4 +- 10 files changed, 350 insertions(+), 68 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabAccountWatcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt 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/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/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) } From 5fd8be64fde9d1b34c36fa3f651eebf85f87ebdc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:59:18 -0400 Subject: [PATCH 08/43] fix(podcast): clamp V4V fee splits so a feed cannot multiply payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `computeShares` paid each fee recipient `totalMilliSats * split / 100` with no upper bound on `split`, and `split` comes verbatim from a kind-30054 episode event that anyone can publish (`ValueTag.parse` is a bare `fromJson` with no validation). A single `fee:true, split:1000` recipient was therefore paid TEN TIMES the amount the user chose. The `remainder` clamp looked like a safety net but only zeroed the honest recipients; it never touched the fee recipients themselves. Proven by test before fixing: `split:1000` pays 10x, and two fee recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap. This was reachable in the worst possible place. Streaming V4V pays every minute, automatically, so a modest multiplier stays under a typical NWC budget and simply runs — and the on-screen running total tracks the INTENDED amount, so it reads "100 sats" while 1,000 left the wallet, while the streaming error handler suppresses the toast. The ordinary zap button reroutes through the same path for any note carrying a value block, so it was not limited to the streaming toggle. Fees are a percentage off the top, so each is now clamped to 100% and the cumulative total to the remaining budget: the payout can never exceed what the user chose. The existing test asserted the right invariant (`sum <= total`) but only ever ran it on well-formed input, which is why this survived. Co-Authored-By: Claude Opus 4.8 --- .../quartz/podcasts/PodcastValue.kt | 12 ++++++- .../quartz/podcasts/PodcastValueShareTest.kt | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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/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 = From 153191e722d7f7a85983f2cd9841109501ddd517 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:59:33 -0400 Subject: [PATCH 09/43] fix(nip46): stop auto-signing relay AUTH under the default policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `REASONABLE` — the default policy on connect — auto-approved kind 22242 (NIP-42 relay auth). The in-code justification was that the event is ephemeral and bound to one relay and challenge, so it cannot be replayed elsewhere. That is true and beside the point: the requesting app supplies the `relay` and `challenge` tags verbatim, so it never needs to replay — it just asks for a FRESH signature naming any relay it likes. A paired app could therefore, with no prompt, open its own socket to any NIP-42 relay, take the challenge, get 22242 signed, and authenticate to that relay AS THE USER. That yields read access to whatever the relay gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM metadata (who, when, how many) — and burns 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; the comment conflated them. 22242 now falls through to ASK. The existing test asserted the vulnerable behaviour with the same flawed reasoning, so it is inverted here rather than merely extended. Co-Authored-By: Claude Opus 4.8 --- .../signers/NostrSignerPermissionLedger.kt | 14 +++++++---- .../NostrSignerPermissionLedgerTest.kt | 24 ++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) 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..9688631c8b 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 @@ -187,9 +186,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 +237,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/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..c6a0ed1fc0 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))) } From c8be65a02ea614073eb80403e318ab2c2804ec5d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:59:50 -0400 Subject: [PATCH 10/43] fix(concord): require consent for invite links, enforce expiry, fold the head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the Concord invite and moderation paths. **Invite deep links redeemed with zero consent.** `ConcordInviteScreen` called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a link on any web page — or a QR code, or a push — silently caused a connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL fragment (disclosing the user's IP to a third party), a Guestbook JOIN signed by the user's identity published to those relays, and a write to their private community list. No tap, no preview. The screen now opens in an awaiting-consent state and only joins from an explicit Join button. The preview is built entirely from the link itself — base64url and NIP-19 decoding, both pure in-memory — and touches the network for nothing: no relay connection, no signing, no publishing. It shows the relays it would contact so the user can see whom they'd be talking to. The community name lives inside a bundle only those relays can serve, so it is honestly reported as unknown until joining rather than fetched. **Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no production callers at all — the only ones were in a test — so an expired invite redeemed forever. Expiry is now enforced at `classify`, the choke point every redeem path funnels through, with its own result and message so the user knows to ask for a fresh link. **Moderation read the wrong edition.** `ConcordModeration` used `firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order, not the folded head. Once an entity had two or more editions the next one chained off a stale predecessor, forking the chain at an already-used version, and `EditionFold` then resolved the fork by `minByOrNull` on the rumor id — a coin flip. Bans were masked by a down-only healing union; UNBANS and role revocations were not, so they could silently fail to apply. Both call sites now fold to the true head. Regression tests assert the fold-head behaviour under two arrival orders — a single order accidentally puts the head first and passes against the buggy code. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 11 ++ .../amethyst/model/ConcordInviteResult.kt | 7 + .../ui/components/ConcordInviteCard.kt | 93 ++++++++----- .../concord/ConcordInviteScreen.kt | 126 ++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 4 + .../commons/actions/ConcordActions.kt | 11 +- .../commons/actions/ConcordModeration.kt | 23 +++- .../commons/actions/ConcordModerationTest.kt | 80 +++++++++++ .../cord05Invites/ConcordInviteBundle.kt | 23 +++- .../ConcordInviteClassifyTest.kt | 43 ++++++ 10 files changed, 368 insertions(+), 53 deletions(-) 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 9ea8909821..254d094926 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2090,6 +2090,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 @@ -2112,6 +2122,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 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/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/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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 11cb80480c..0ef4fe5dec 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -312,6 +312,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. 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..ebbe1ecbbb 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 @@ -52,6 +52,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( @@ -358,14 +359,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..ef0210be9c 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 @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -55,13 +56,31 @@ import kotlinx.serialization.builtins.serializer * act under so the fold can verify the chain terminates at the owner. */ object ConcordModeration { + /** + * The current head of ([kind], [entityId]) within [current], or null if the entity + * has no editions yet. + * + * [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 would fork the chain at an + * already-used version, and [EditionFold] would then break the tie by + * `minByOrNull { rumorId }` — a coin flip that can silently drop the new edition + * (an unban or a role revocation quietly failing to apply). Fold the entity's + * chain instead, exactly as every reader does. + */ + private fun headOf( + current: List, + kind: ControlEntityKind, + entityId: ByteArray, + ): ControlEdition? = EditionFold.foldEntity(current.filter { it.entityKind == kind && it.entityId.contentEquals(entityId) }) + /** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */ private fun versioning( current: List, kind: ControlEntityKind, entityId: ByteArray, ): Pair { - val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) } + val head = headOf(current, kind, entityId) return if (head != null) (head.version + 1) to head.hash else 0L to null } @@ -184,7 +203,7 @@ object ConcordModeration { communityId: ByteArray, ): Set { val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) - val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) } + val head = headOf(current, ControlEntityKind.BANLIST, entityId) return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet() } 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..05e6c5ad15 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() = @@ -94,4 +99,79 @@ class ConcordModerationTest { 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)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L)), 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) + 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)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L)), cp) + + for (arrival in listOf(editions.toList(), editions.reversed())) { + val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Owner"), arrival, createdAt = 4L)), cp).single() + assertEquals(2L, third.version) + + val state = ConcordCommunityState.fold(editions + third, community.ownerPubKey) + assertEquals("Owner", state.roles[roleId.toHexKey()]?.name) + } + } } 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..bf3e3ee336 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,15 +131,25 @@ 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) + } } /** 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))) From 2ccd837f30b258373045fd3af14df60f52edd709 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 19:38:52 -0400 Subject: [PATCH 11/43] fix(napplet): sign as the account a surface was launched as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests resolved their signer through `sessionManager.loggedInAccount()` — whichever account is active *right now* — with no binding to the surface that asked. A full-screen host is a separate activity that an account switch does not tear down, so: - Open a site full-screen as A and log in via NIP-07: the page shows A. - Switch to B in the main app. - Return to that still-open surface and request a signature: the broker handed it **B's** key. Confirmed on device before the fix. Worse than a mismatched prompt: B's session was then written into **A's** WebView storage jar, so afterwards even the embedded tab — which rebuilds correctly and had been verified correct — displayed the wrong account. Per-account isolation held only until a full-screen surface wrote a foreign session into a jar. And it happened silently, because the ledger is per-account+origin and B had already granted "always allow" for that origin from an earlier session. `NappletLaunchRegistry.Session` now carries the account that minted the token, and the broker resolves *that* account out of the cache. This needs no new machinery to satisfy both halves of the rule: embedded surfaces are torn down and re-minted on a switch, so they follow the active account, while a full-screen surface keeps the account it was opened with. It also extends an argument the code already made — the sandbox can only act as the napplet it was launched as, because it holds only its own token; now the same is true of the account. Fails closed: if the launch account is no longer loaded, the request is refused rather than falling back to whoever is signed in now. The same live-account resolution existed on two adjacent paths, fixed here too: - Relay subscriptions took the account from a global supplier, so a full-screen surface's REQs would target the newly-active account's relays while its signatures came from the old one. The account is now passed per-open from the launch token. - `identity.changed` streamed the app's active account, so a page bound to A could be told it had become B while signatures still returned A — the same desync inverted. It is now bound to the surface's own account and reports only that account going away. Verified on device: with B active, a fresh identity read from a full-screen surface launched as A returns **A**; the embedded tab still follows B; both surfaces ran simultaneously under different accounts with no cross-writes between jars. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/napplet/NappletBrokerService.kt | 61 +++++++++++++------ .../amethyst/napplet/NappletIdentityWatch.kt | 9 ++- .../amethyst/napplet/NappletLaunchRegistry.kt | 16 ++++- .../amethyst/napplet/NappletLauncher.kt | 11 +++- .../napplet/NappletLiveSubscriptions.kt | 13 ++-- 5 files changed, 82 insertions(+), 28 deletions(-) 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 0367a3c9c2..7b37c290b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -50,7 +50,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 @@ -93,17 +93,23 @@ class NappletBrokerService : Service() { // 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. @@ -241,7 +247,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 +287,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,14 +339,29 @@ 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 + private fun brokerFor(accountPubKey: HexKey): NappletBroker? { + val account = accountFor(accountPubKey) ?: return null cachedBroker?.let { (acc, broker) -> if (acc === account) return broker } val broker = AccountNappletGateways( 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 c6da22a3ba..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. 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)) From 996b800da535a1d34593b131a124d4b2e63ac72f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 19:52:52 -0400 Subject: [PATCH 12/43] docs(changelog): correct claims the code does not support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the v1.13.0 notes against the implementation found several claims that are wrong or overstated. These are user-facing release notes, so a false claim changes what people believe about their own security. Corrected: - **Privacy Lock.** The notes said it gates Messages "behind a password or biometric" on "Android and Desktop", with inactivity auto-lock and optional preview redaction. It does not exist on Android at all — no code, no settings entry — and there is no biometric implementation on any platform (`CredentialPrompter` has no implementations). Preview redaction persists a setting nothing reads, and the auto-lock is a fixed timer, since the idle-reset modifier is never applied. It is also a screen gate rather than encryption at rest: the account stays live and messages keep syncing while locked. Now described as what ships — a password gate on the Desktop Messages and Wallet columns. - **"Every signature, payment, or data read needs your explicit approval."** Only payments require per-use consent; everything else can be granted once and reused, and the default trust level auto-signs notes, reactions and encryption after a single tap. - **Web of Trust (GrapeRank)** was listed under app features, but crawling and scoring exist only in the `amy` CLI — no app module references it. The app consumes NIP-85 cards published by an operator, which is what the entry now says. - **"One-tap trust for your follows' relays"** described bulk-granting relays used by people you follow. What exists is category rules evaluated per challenge. - **Pinned web apps "show the app's own icon"** — they render a generic placeholder. - **PoW** contradicted itself: "all cores" in one entry, "half the device's cores" in another. The latter matches `PoWPolicy.minerWorkers`. - **Onion-Location "through every HTTP client"** — the Android app's clients only; the desktop, CLI, geode and sandbox blob clients don't install it. - **Relay hardening** claimed REQ refusals stop immediately and failures evict "on the first strike"; both take repeated failures, and the first-strike eviction applies only to crawls. - **`bunker://` links cannot be pasted in** — Amethyst only emits them. - **Git code browser** needs a repository with an http(s) clone URL. - **Geohash anonymous identity** — the per-area identity is unlinkable, but the optional nickname is one global handle, so setting it links your posts across areas. - **Concord ban** is read-time enforcement for everyone else; the banned member keeps the keys until a Refounding. Adds an Upgrading section for the user-visible effects of the per-account isolation work: sites signed out once, permissions re-asked per account, and relay logins now prompting under the default remote-signer policy. Contributor and translator credits are left as they are — several entries are unresolved npubs and Crowdin-generated usernames, but correcting attribution is not a call to make from the code. Co-Authored-By: Claude Opus 4.8 --- docs/changelog/v1.13.00.md | 83 +++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 33 deletions(-) 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 From c9c91a8c98284995f04d0b1ead7dfe901b88e248 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:00:50 -0400 Subject: [PATCH 13/43] fix(concord): rank-gate role editions and harden rotation authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two authority holes, plus the limit that remains. **Role editions had no rank gate.** Grant editions are correctly gated — a granter must hold MANAGE_ROLES *and* strictly outrank every role it hands out. Role editions checked only that the author held MANAGE_ROLES. Nothing stopped an authorized signer editing a role at or above its own rank, including the role it holds itself. So a moderator at position 5 with MANAGE_ROLES could publish one edition on their own role's chain claiming position 1 and every permission bit, then a second demoting the real admins beneath them — reaching full authority over everyone but the owner in two editions. The rogue-higher-version defence added earlier does not cover this: it drops editions from *unauthorized* signers, and this signer is authorized. Role editions are now gated in both directions: an author may not claim a position at or above its own rank, may not touch a role that already sits at or above it, and may not hand a role permission bits it does not hold itself. Deleting keeps only the second rule, so retiring a role beneath you still works. The owner is unaffected. **A banned moderator could still rotate the community.** The rekey receive path authorized the rotator with `effectivePermissions(...)`, which ignores the banlist, rather than `hasPermission(...)`, which excludes banned members. Now uses the latter. **The owner no longer adopts a root someone else minted.** A rotation replaces the community root, so a rotator who *includes* the owner as a recipient hands themselves the keys to the owner's own community — the owner would follow them onto an attacker-chosen epoch. The owner changes epoch only by rotating themselves. **Known limit, documented at the call site.** A rotation carries only (newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot tell who was omitted. A BAN-holder can therefore still evict the owner by leaving them out: everyone else adopts, the owner is stranded on the old epoch. That is not fixable in the receive path; it needs a protocol change (a recipient commitment the receiver can check, or owner co-signing). Tracked for CORD-06. The escalation test was verified to fail with the gate disabled, and a companion test asserts an admin can still edit a role beneath it, so the gate is not merely blocking everything. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 28 ++++++-- .../concord/cord04Roles/AuthorityResolver.kt | 35 +++++++++- .../cord04Roles/AuthorityResolverTest.kt | 70 +++++++++++++++++++ 3 files changed, 126 insertions(+), 7 deletions(-) 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 254d094926..22d57b454b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2602,10 +2602,19 @@ 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. + * + * KNOWN LIMIT — a rotation carries only (newRoot, newEpoch, rotator); there is no + * recipient list, so a receiver cannot tell who was left out. A BAN-holder can + * therefore still evict the OWNER by simply omitting them: every other member + * adopts, the owner receives nothing and is stranded on the old epoch. Refusing a + * foreign rotation as the owner (above) stops the worse variant — being carried + * onto an attacker-minted root — but not exclusion. Closing that needs a protocol + * change: a recipient commitment the receiver can check the owner against, or + * owner co-signing of a rotation. Tracked for CORD-06. */ private suspend fun drainConcordRekeys() { if (!isWriteable()) return @@ -2623,7 +2632,16 @@ 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) + + // The owner never adopts a root someone else minted. A rotation replaces the community + // root, so a rotator who includes the owner as a recipient would hand themselves the keys + // to the owner's own community — the owner would follow them onto an attacker-chosen + // epoch. The owner changes epoch only by rotating themselves. + if (authority.isOwner(signer.pubKey) && !received.rotator.equals(signer.pubKey, ignoreCase = true)) continue + + // 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) } 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..c653414959 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 @@ -161,8 +172,28 @@ class AuthorityResolver private constructor( val newRoles = HashMap() for ((entity, chain) in roleChains) { val head = - EditionFold.foldEntity(chain.filter { it.author.lowercase() == ownerLower || holdsManageRoles(it.author.lowercase()) }) - ?: continue + EditionFold.foldEntity( + chain.filter { e -> + val author = e.author.lowercase() + if (author == ownerLower) return@filter true + if (!holdsManageRoles(author)) return@filter false + val authorRank = rankOf(author) ?: return@filter false + val r = ConcordJson.decodeOrNull(e.content) ?: return@filter 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@filter false + if (!r.deleted && r.position <= authorRank) return@filter false + // Nor may it grant bits the author does not itself hold, which would + // otherwise escalate through a role rather than through a grant. + r.deleted || bitsOf(author).hasAll(r.permissionBits()) + }, + ) ?: 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 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..a98608cb15 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,76 @@ 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 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 = From 0f53eb09a24e1517ba0b0516ede01d6650630f52 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:10:36 -0400 Subject: [PATCH 14/43] fix(concord): gate revokes on target rank; drop the owner rotation refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed the previous commit's authority changes against Armada (gitlab.com/soapbox-pub/armada), the reference Concord client we interop with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so no code was taken from it. It confirmed the role rank gate (Armada checks both directions too: the author must outrank the position being minted AND the standing position being replaced) and the banlist check on rotation. It also showed one of our rules was wrong and one gate was missing. **Removes the owner's refusal of foreign rotations.** The previous commit made the owner ignore any rotation it did not author, reasoning that a BAN-holder could otherwise carry the owner onto a root of their choosing. Armada does the opposite on purpose — "authority is the roster, never key possession" — and it is right: an admin legitimately rotating to remove a spammer would leave the owner alone on the dead epoch, self-inflicting the strand the rule was meant to prevent, and diverging from the reference implementation forks communities across clients. The threat is better answered by the rank gate: with role editions gated, nobody can escalate themselves to BAN, so BAN-holders are people the owner deliberately trusted. **Adds the missing rank gate on grants.** A grant was authorized if the granter outranked every role it handed out — but a REVOKE carries no role ids, and `all {}` over an empty list is vacuously true. So any MANAGE_ROLES holder could strip anyone's roles, the owner's admins included: promotion was gated, demotion was free. Armada treats a grant as an action ON the member and requires outranking the target's standing rank; this now does the same. Both new tests were verified to fail with the corresponding check disabled, and each has a companion asserting the legitimate case still works (an admin can still revoke a moderator beneath it). Also records what Armada does about exclusion, since we cannot prevent it receiver-side: it does not try to. A rotation carries no recipient list there either, so a stranded member instead re-resolves the invite link they joined through and merges forward to the higher epoch ("stranded recovery"). Amethyst has no equivalent, so a stranded member stays stranded — noted at the call site as follow-up work. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 25 ++++--- .../concord/cord04Roles/AuthorityResolver.kt | 11 ++- .../cord04Roles/AuthorityResolverTest.kt | 70 +++++++++++++++++++ 3 files changed, 91 insertions(+), 15 deletions(-) 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 22d57b454b..bbf4a6e3e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2607,14 +2607,19 @@ class Account( * 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. + * * KNOWN LIMIT — a rotation carries only (newRoot, newEpoch, rotator); there is no - * recipient list, so a receiver cannot tell who was left out. A BAN-holder can - * therefore still evict the OWNER by simply omitting them: every other member - * adopts, the owner receives nothing and is stranded on the old epoch. Refusing a - * foreign rotation as the owner (above) stops the worse variant — being carried - * onto an attacker-minted root — but not exclusion. Closing that needs a protocol - * change: a recipient commitment the receiver can check the owner against, or - * owner co-signing of a rotation. Tracked for CORD-06. + * recipient list, so a receiver cannot tell who was left out, and a BAN-holder can + * evict anyone (the owner included) by omission. Armada does not prevent this + * either; it *recovers* from it, re-resolving the invite link the member joined + * through and merging forward to the higher epoch ("stranded recovery"). Amethyst + * has no equivalent yet, so a stranded member stays stranded. Tracked for CORD-06. */ private suspend fun drainConcordRekeys() { if (!isWriteable()) return @@ -2633,12 +2638,6 @@ class Account( if (received.newEpoch <= entry.rootEpoch) continue val authority = session.state.value?.authority ?: continue - // The owner never adopts a root someone else minted. A rotation replaces the community - // root, so a rotator who includes the owner as a recipient would hand themselves the keys - // to the owner's own community — the owner would follow them onto an attacker-chosen - // epoch. The owner changes epoch only by rotating themselves. - if (authority.isOwner(signer.pubKey) && !received.rotator.equals(signer.pubKey, ignoreCase = true)) continue - // 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) 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 c653414959..3c409f4127 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 @@ -212,8 +212,15 @@ class AuthorityResolver private constructor( 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 } + // Must strictly outrank each assigned role that actually exists... + if (!g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return@filter 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()) + targetRank == null || granterRank < targetRank }, ) ?: continue val g = ConcordJson.decodeOrNull(head.content) ?: continue 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 a98608cb15..1c6034d1df 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 @@ -123,6 +123,76 @@ class AuthorityResolverTest { 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. From 30f463895458646eb7b2b1f6226342fdcc682330 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:35:50 -0400 Subject: [PATCH 15/43] fix(concord): re-key observed members on a Refounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self` — it never consulted `observedAuthors`, the members seen publishing to a channel. `ConcordCommunitySession.allMembers()` already unions them in (and already subtracts the banned); the rotation path just didn't use it. Amethyst announces a Guestbook Join, but nothing in the protocol requires one, so in practice most members of a cross-client community have never sent one. Every member who had only ever *posted* — no role, no Join — therefore received no rekey blob and was silently expelled by the next rotation. Removing a single spammer would quietly strand most of the community, and it fell hardest on Armada members, who are the bulk of the roster in the communities we interop with. Now uses `allMembers()`. Still a floor rather than a census, as its KDoc says: a member who joined silently, holds no role, and has never posted leaves no trace to re-key, and nothing here can find them. Stranded recovery is what brings those back. Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc said self-certification stops a bundle smuggling "a false owner or a fake key for a real community". Only the first half is true: `community_id` commits to (owner, salt) — both public in any invite — and NOT to `community_root`, so an attacker can mint a bundle carrying a real community's id, owner and salt beside 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. That is a CORD-05 limitation, not an implementation gap: Armada's `validateBundle` checks exactly the same thing and also leaves the root unbound, so a stricter unilateral rule would break interop while protecting nobody. 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 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); the KDoc now says so instead of claiming a guarantee the code does not provide. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 17 ++++++++++---- .../cord05Invites/ConcordInviteBundle.kt | 22 ++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) 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 bbf4a6e3e2..e6617f5ade 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2494,7 +2494,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. @@ -2523,10 +2523,19 @@ class Account( 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) 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 bf3e3ee336..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 @@ -153,9 +153,25 @@ object ConcordInviteBundle { } /** - * 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 From fac1bf5b5d317fd5dfb8105dde7209f2e6f47971 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:36:25 -0400 Subject: [PATCH 16/43] feat(concord): refuse Control-Plane rollbacks with a version floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConcordRefounding.compactControlPlane` re-wraps one edition per entity when a community rotates epoch, and the ROTATOR chooses which one survives. The receiving side had no memory: `refold()` folds only the wraps at the current epoch's Control-Plane address and discards the prior epoch's buffer, and `EditionFold` accepts whatever it is handed (its no-genesis fallback anchors at the lowest version present). So a rotator could publish only version 1 of a chain and omit version 2 — restoring a revoked role, clearing a banlist, reverting metadata. Every signature is genuine; this is rollback by omission, not forgery. Adds a per-entity floor: the version AND hash last successfully folded. - **No floor (fresh joiner)** — unchanged: genesis anchor, else the lowest-version edition as the legitimate compaction bootstrap. - **With a floor** — the walk is anchored AT the floor: the offered set must contain the exact edition already folded (version and hash; a same-version sibling is a fork, not our chain), then walks up. A head below the floor is structurally unreachable. - **Gap** (the floor edition is absent) — refuse, and keep the known head. Refusing by *retaining* matters here: this fold is recomputed from scratch each time, so letting an entity vanish would itself be a rollback — a dropped banlist is an unban. The floor needs no new persistence. It is derived from `heldRoots`, the rotated-out access roots already persisted in the NIP-44 self-encrypted kind-13302 list: the session derives each prior epoch's Control-Plane address from them, folds oldest-first, and takes the resulting heads as the floor. That survives both a process restart and the session rebuild `ConcordSessionRegistry.sync` performs at exactly the moment of a Refounding — which would have destroyed any in-session floor. If the old planes are not served, there is no floor and behaviour is as before. Floors are built from AUTHORITY-GATED heads, not raw ones. Without that, any ex-member still holding a rotated-out root could mint a high-version edition on the old plane and freeze the entity for every honest client — a denial of service this change would otherwise have introduced. Covered by a test. Verified by disabling both enforcement points: 7 of 12 quartz tests and the end-to-end commons test fail, and the ones that still pass are exactly the non-regression cases (fresh joiner, honest compaction, pass-through without floors). Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the edition pool and does not carry floors itself; gating happens at the pool level before the resolver sees anything. Sound, but connectivity checked on the full set is a weaker precondition than on each subset — passing floors into the resolver's three folds is worth a follow-up. Co-Authored-By: Claude Opus 4.8 --- .../actions/ConcordSubscriptionPlanner.kt | 28 ++- .../model/concord/ConcordCommunitySession.kt | 77 +++++- .../model/concord/ConcordSessionRegistry.kt | 3 + .../model/concord/ConcordRollbackFloorTest.kt | 233 ++++++++++++++++++ .../cord02Community/ConcordCommunityState.kt | 66 ++++- .../quartz/concord/cord04Roles/EditionFold.kt | 173 +++++++++++-- .../cord04Roles/EditionFoldFloorTest.kt | 174 +++++++++++++ 7 files changed, 729 insertions(+), 25 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt 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/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/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..f571432ccc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.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.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, + ) + 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, + ) + 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, + ) + + 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/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..98a54f0150 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,69 @@ 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) + val gated = + list.filter { + authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit)) + } + for ((entity, head) in EditionFold.fold(gated, floors)) { + // 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. @@ -84,7 +144,7 @@ class ConcordCommunityState( // authorized editions, then take the highest-version head (guarding against strays). val metadata = EditionFold - .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)) + .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA), floors) .values .maxByOrNull { it.version } ?.let { ConcordJson.decodeOrNull(it.content) } @@ -92,7 +152,7 @@ class ConcordCommunityState( // Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized // editions only, dropping the tombstoned ones. val channels = LinkedHashMap() - for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) { + for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS), floors).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/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 3b8e3ff2ea..dc2a898e9f 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,49 @@ object EditionFold { } return head } + + /** + * 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/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 }) + } +} From f8d8a2b1359c17dedc63a12f3003246229a0baea Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:36:52 -0400 Subject: [PATCH 17/43] feat(concord): recover a membership stranded by a Refounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rotation carries only (newRoot, newEpoch, rotator) — no recipient list — so a member simply left out of the recipient set receives nothing and is stranded on the dead epoch forever while everyone else moves on. It applies to any member, the owner included, and cannot be prevented on the receive side: there is nothing to check. Armada does not prevent it either; it recovers, and this follows the same approach. The invite link a membership was joined through is stored as the anchor, and when that link later resolves to a HIGHER epoch, the membership merges forward. Uses Armada's exact wire names so the kind-13302 list stays compatible in both directions: `invite_ref` (the link in bare `#` form, host-stripped so a link minted by a different front end reduces to the same anchor) and `excluded_at_epoch`, both at the entry level. Details that decide whether this works at all: - `merge()` lets a higher-epoch winner inherit the loser's `invite_ref` when it has none. Without it a two-device merge silently discards the only anchor recovery has, disarming it permanently. - `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a rotation; it rebuilt the entry field-by-field and would have dropped them at exactly the moment they matter. - Merging forward preserves `heldRoots`, so prior-epoch history the member legitimately holds is not lost by recovering. - Recovery requires a strictly higher epoch and a matching community id, so it is monotonic and cannot be steered by an unrelated bundle. Hooked onto the existing Concord revision tick immediately after `drainConcordRekeys`, because the two are halves of one problem: a rotation you were included in arrives as a rekey to drain, one you were excluded from produces no message at all and can only be found by polling the link. Rate-limited to 15 minutes per community; an idle tick costs a map lookup. Only a Live bundle recovers — an expired or revoked link is not a missed rotation. Verified by mutating the production code eight ways (dropping the anchor, dropping heldRoots, dropping the epoch comparison, renaming the wire keys, removing the merge inheritance, breaking bare-form parsing) and confirming each produced exactly the expected failures. Two things this surfaced, both left for their own change: - Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys` plus closed DTOs — while Armada's format ends in `[k: string]: unknown`. So every Amethyst write of the community list silently strips fields Armada added that we do not model; it already discards the `refounder` field we parse but never re-emit. That is live interop data loss, caused by us, independent of this work. - `mergeForward` keeps the entry's existing private-channel grants rather than adopting the bundle's, matching what `joinConcordViaInvite` already does. If a recovered member should pick up new-epoch grants, both paths need it. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 92 ++++++- .../commons/actions/ConcordActions.kt | 19 ++ .../cord02Community/ConcordCommunityList.kt | 75 ++++-- .../cord05Invites/ConcordInviteLink.kt | 27 +- .../cord05Invites/ConcordStrandedRecovery.kt | 97 +++++++ .../ConcordStrandedRecoveryTest.kt | 255 ++++++++++++++++++ 6 files changed, 541 insertions(+), 24 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt 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 e6617f5ade..d0c2a94ba8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -362,6 +362,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 @@ -371,6 +372,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( @@ -2146,6 +2155,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) @@ -2603,6 +2616,10 @@ 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, ) sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) @@ -2623,12 +2640,11 @@ class Account( * which forks a community across clients. Self-escalation to BAN is prevented * upstream by the role rank gate in AuthorityResolver. * - * KNOWN LIMIT — 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. Armada does not prevent this - * either; it *recovers* from it, re-resolving the invite link the member joined - * through and merging forward to the higher epoch ("stranded recovery"). Amethyst - * has no equivalent yet, so a stranded member stays stranded. Tracked for CORD-06. + * 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 @@ -2655,6 +2671,67 @@ class Account( } } + // 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 @@ -5410,6 +5487,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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index ebbe1ecbbb..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 @@ -351,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, 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..5f2609d746 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 @@ -49,6 +49,15 @@ 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. */ @Serializable class ConcordCommunityListEntry( @@ -62,6 +71,8 @@ class ConcordCommunityListEntry( val relays: List = emptyList(), val name: String = "", val addedAt: Long = 0, + val inviteRef: String? = null, + val excludedAtEpoch: Long? = null, ) /** @@ -118,6 +129,8 @@ object ConcordCommunityList { val seed: JoinMaterialWire? = null, val current: JoinMaterialWire? = null, @SerialName("added_at") val addedAt: Long = 0, + @SerialName("invite_ref") val inviteRef: String? = null, + @SerialName("excluded_at_epoch") val excludedAtEpoch: Long? = null, ) @Serializable @@ -145,19 +158,24 @@ object ConcordCommunityList { heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) }, ) - 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, + ) = 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, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + ) // ---- build / codec -------------------------------------------------------- @@ -183,6 +201,8 @@ object ConcordCommunityList { seed = jm, current = jm, addedAt = e.addedAt, + inviteRef = e.inviteRef, + excludedAtEpoch = e.excludedAtEpoch, ) }, tombstones = emptyList(), @@ -206,7 +226,7 @@ object ConcordCommunityList { 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) + (e.current ?: e.seed)?.toEntry(e.addedAt, e.inviteRef, e.excludedAtEpoch) } } catch (_: Exception) { emptyList() @@ -238,8 +258,33 @@ 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, + ) } 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..f3ab0bd286 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -0,0 +1,97 @@ +/* + * 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, + ) + } +} 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) + } +} From 5c23f8490d923cca6156a67086480985ccde0b14 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:59:19 -0400 Subject: [PATCH 18/43] fix(concord): stop destroying other clients' data in the community list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private community list (kind 13302) is documented as wire-compatible with Armada's `communityList.ts`, whose entry type ends in `[k: string]: unknown` — unknown keys are part of the contract, and `ConcordJson`'s own KDoc says shapes are "deliberately client-extensible (CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable` DTOs meant decode dropped every unmodelled key and encode never restored it, so **every Amethyst write of a user's list silently stripped fields another client had written**, across every community in it. Already proven, not hypothetical: `JoinMaterialWire` declared a `refounder` field that nothing in the repo reads, so it was parsed and destroyed on the first write. We only avoided destroying Armada's `invite_ref`/`excluded_at_epoch` because those were modelled hours ago, for stranded recovery — the anchor recovery depends on would otherwise have been deleted on every write. Each wire DTO's compiler-generated serializer is now wrapped in a shared `JsonTransformingSerializer` that lifts unknown keys into a bag on decode and merges them back on encode, with declared fields winning on conflict. The known-key set is read from the descriptor rather than hand-listed, so it cannot drift from the DTO. Preserved at the document root, each entry, the `current` join material, each channel, each held_root, each tombstone, and everything nested inside `seed`. `refounder`'s typed field is removed so it round-trips generically. Two further data-loss bugs surfaced while doing it, both fixed here: - **`seed` was overwritten with `current` on every write**, destroying the immutable join anchor. It is now kept and re-emitted verbatim as a raw JsonObject — we never hydrate from it while `current` exists, so we have no business rewriting it, and keeping it raw preserves everything nested inside for free. - **`tombstones` were re-encoded as an empty list**, which did not just lose their unknown keys: it RESURRECTED communities another client had deliberately removed. They are now carried verbatim. Verified by four separate sabotage passes (no-op the transform, re-mint `seed`, restore the empty-tombstone write, flip the merge order); each new test fails under at least one, and every mechanism is covered. Control-plane re-serialization was audited too and is NOT fixed here: `compactControlPlane` is safe (it re-wraps the original seal verbatim), but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and the channel edits — construct fresh typed entities and re-encode, so they drop extensions the same way. Fixing those means merging into the head edition's raw JsonObject on each edit path, which is a larger change than this should carry. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../model/concord/ConcordChannelListState.kt | 27 +- .../cord02Community/ConcordCommunityList.kt | 265 +++++++++++++++--- .../ConcordCommunityListEvent.kt | 16 +- .../cord05Invites/ConcordStrandedRecovery.kt | 3 + .../ConcordCommunityListTest.kt | 243 ++++++++++++++++ 6 files changed, 506 insertions(+), 51 deletions(-) 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 d0c2a94ba8..bc9783e547 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2620,6 +2620,9 @@ class Account( // 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) 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/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 5f2609d746..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, ) /** @@ -58,6 +130,9 @@ class PrivateChannelKey( * 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( @@ -73,6 +148,7 @@ class ConcordCommunityListEntry( val addedAt: Long = 0, val inviteRef: String? = null, val excludedAtEpoch: Long? = null, + @Transient val residue: ConcordEntryResidue = ConcordEntryResidue.EMPTY, ) /** @@ -86,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 @@ -101,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 @@ -116,35 +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, @SerialName("invite_ref") val inviteRef: String? = null, @SerialName("excluded_at_epoch") val excludedAtEpoch: Long? = null, - ) - - @Serializable - private class CommunityTombstoneWire( - @SerialName("community_id") val communityId: String, - @SerialName("removed_at") val removedAt: Long = 0, + @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, @@ -152,29 +304,32 @@ 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, 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) }, - privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, + 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 -------------------------------------------------------- @@ -184,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 = @@ -198,16 +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) } /** @@ -215,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, e.inviteRef, e.excludedAtEpoch) + 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. */ @@ -286,5 +476,6 @@ object ConcordCommunityList { 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/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index f3ab0bd286..1baf91cb5f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -92,6 +92,9 @@ object ConcordStrandedRecovery { 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/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)) From c84acddc0c6e1380cab3c8b5d3ad6d46ed471a83 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:59:44 -0400 Subject: [PATCH 19/43] fix(concord): renaming a private channel no longer makes it public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renameConcordChannel` built `ChannelEntity(name = ...)` from scratch without reading the standing definition. `private`, `voice` and every other flag default to false, so the published edition declared the channel PUBLIC and TEXT. Renaming a private channel silently reclassified it; renaming a voice channel silently converted it. The messages themselves stay encrypted — a private channel is independently keyed and a rename does not rotate that key — but the Control Plane, which is what every client reads to decide how to present and gate a channel, now says the wrong thing about it. A moderator fixing a typo in a channel name should not change who a client believes may read it. `deleteConcordChannel` had the same shape: its tombstone also reset the flags, so retiring a channel reclassified it on the way out. Both now carry the standing definition forward and change only what they mean to change. Found while auditing which write paths re-serialize a decoded entity rather than re-wrapping it — the same audit that produced the community list fix. This one is worse than the unknown-key loss it was found beside, because it discards KNOWN fields with a privacy meaning rather than fields we merely failed to model. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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 bc9783e547..4c131fb678 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2782,7 +2782,15 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val channel = ChannelEntity(name = name.trim()) + // 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()) publishConcordWrap(session.entry, wrap) return true @@ -2796,7 +2804,14 @@ class Account( ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val channel = ChannelEntity(name = name.trim(), deleted = true) + // 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()) publishConcordWrap(session.entry, wrap) return true From 6045e288308499a79a66850f0fea35f1b902cb4d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 21:02:59 -0400 Subject: [PATCH 20/43] fix(cashu): validate a token's mint URL before contacting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MintHttpClient` only trimmed trailing slashes — no scheme check, no host check — and `token.mint` comes verbatim from any pasted or posted Cashu token. Tapping Redeem on a token in someone's note therefore made the device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:`, LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all — plus it disclosed the user's IP to whoever controlled the URL. Validation now runs in the constructor, so no caller can issue a request before it. The rule: `https://` to a public host, or `http://` to a `.onion` host, and nothing else. Onion mints matter — a blanket "https only" rule would have silently broken every Tor mint. Rejected hosts cover the private/loopback/link-local/unique-local ranges plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host, so allowing them buys nothing and leaks reachability. The bypasses are what make this worth care, and each has a test: IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`), the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`, `127.1`), trailing-dot hosts, and userinfo disguise (`https://mint.example.com@127.0.0.1/`) — handled by splitting on the LAST `@`. The host parse is hand-rolled rather than delegated to `java.net.URI`/`HttpUrl` precisely because those normalise these forms inconsistently. A mint the user added to their own wallet is exempt from the host and https rules — a self-hosted mint on a LAN is a legitimate setup, and the threat here is a *pasted, untrusted* token pointing inward, not a mint the user chose. The exemption never relaxes the scheme check. It is threaded properly rather than TODO'd: the melt path passes the wallet's known mints and marks the mint user-configured only on a match; the wallet ops and CLI pass it directly, since those URLs are the user's own. Refusal gets its own message rather than reusing the mint-error string, whose wording would have misattributed our own refusal to the mint. DNS rebinding is out of scope and noted in a comment — the check runs pre-resolution and cannot defend against a host that resolves differently on the second lookup. Verified by disabling the scheme and host checks: 11 of 20 tests fail, every rejection case among them, and every allow case still passes. Co-Authored-By: Claude Opus 4.8 --- .../service/cashu/melt/MeltProcessor.kt | 20 +- .../ui/screen/loggedIn/AccountViewModel.kt | 13 +- amethyst/src/main/res/values/strings.xml | 2 + .../cli/commands/cashu/CashuMintCommands.kt | 5 +- .../commons/cashu/ops/CashuWalletOps.kt | 9 +- .../mintApi/CashuMintUrlValidator.kt | 317 ++++++++++++++++++ .../nip60Cashu/mintApi/MintHttpClient.kt | 12 +- .../mintApi/CashuMintUrlValidatorTest.kt | 223 ++++++++++++ 8 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidatorTest.kt 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 143ba838c4..fd8e385c2d 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 @@ -2380,7 +2380,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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0ef4fe5dec..97148847fb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2605,6 +2605,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/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/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/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") + } +} From 7792c42f1d2920c9591a5c0379c86bf96fd72e38 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 21:03:20 -0400 Subject: [PATCH 21/43] fix(blossom): bound what a paid server can extract on a 402 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in the pay-to-upload path, all exploitable by a hostile or merely broken media server. **No amount cap, and the shown amount was not the paid amount.** `amountSats()` was display-only — `pay()` never read it, so a server asking 10,000,000 sats was handled exactly like one asking 10. `pay()` now takes the amount the dialog showed, re-derives it from the invoice, and refuses on over-cap, on mismatch with the dialog, and on an amountless invoice (a payee-chosen amount must never be paid unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are single-digit to low-hundreds, so this is one to two orders of magnitude above legitimate use and caps one prompt's damage without blocking anyone. **Unbounded re-prompting.** The post-payment retry caught generic `Exception` — including a second `BlossomPaymentException` — marked the target missing, then re-entered the mirror path, so a server that pocketed the preimage and replied 402 again got an indefinite pay-prompt cycle. A new prompt ledger allows one prompt per (blob, target) per user-initiated action; the user's own tap resets it, the automatic continuation does not. **Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait without cancelling the in-flight NWC request, so a payment settling at second 91 was reported as failure and could be paid again. NIP-47 `sendZapPaymentRequestFor` is fire-and-forget with no cancellation and does not return a request id, so cancelling is not reachable. Instead the invoice is claimed before sending and released only on a definitive wallet answer — the timeout path deliberately does not release it, so it can never be sent twice. Known limit: the claim is process-lifetime, so a timed-out invoice becomes payable again after a restart; persisting it needs a real store. **Unsanitised server text.** The server-controlled `X-Reason` header was rendered verbatim directly above the pay button, letting a hostile server assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice. It is now stripped of control characters and Unicode bidi overrides, whitespace-collapsed, clamped to 200 chars, and rendered in a distinct style as ` says: "…"` so it cannot be mistaken for our wording. The two policies are small standalone classes so they are testable without an Account graph. Verified by disabling all four checks: 12 of 15 tests fail, covering every defect; the 3 that stay green are the intended negative controls. Not covered: the background mirror sweep does not go through this handler and cannot prompt, so it was never exposed to the cap or re-prompt defects, and it inherits the invoice claim and sanitisation for free. `pay()` itself is still not end-to-end tested — that needs Account mocking well beyond this fix. Co-Authored-By: Claude Opus 4.8 --- .../uploads/blossom/BlossomPaymentHandler.kt | 128 +++++++++- .../uploads/blossom/InFlightInvoices.kt | 60 +++++ .../uploads/blossom/PaymentPromptLedger.kt | 52 ++++ .../mediaServers/BlossomBlobManagerScreen.kt | 25 +- .../BlossomBlobManagerViewModel.kt | 63 ++++- amethyst/src/main/res/values/strings.xml | 1 + .../blossom/BlossomPaymentSafetyTest.kt | 228 ++++++++++++++++++ .../nipB7Blossom/BlossomPaymentRequired.kt | 34 +++ 8 files changed, 566 insertions(+), 25 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/InFlightInvoices.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/PaymentPromptLedger.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentSafetyTest.kt 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 58d1cb0891..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,6 +40,85 @@ 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, @@ -58,15 +137,32 @@ object BlossomPaymentHandler { } /** - * 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 { @@ -76,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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97148847fb..e74fab5127 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1629,6 +1629,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 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/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. From 73d59a29bfa8d6befe32a02d7ceb80d3c55de98e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 21:28:05 -0400 Subject: [PATCH 22/43] fix(nip46): gate identity reads on pairing; make decrypt consent informed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems in the remote signer, both about a client getting something without the user meaningfully agreeing to it. **`get_public_key` and `get_relays` answered anyone.** Every other method runs through `ifAuthorized`; these did not, and nothing required a prior successful `connect`. The service decrypts and dispatches any well-formed kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a malicious app, posted for support, leaked in a screenshot — could ask it which account it belongs to, without the secret and without connecting. `get_relays` additionally handed over the inbox relay set. That defeated the transport/identity split, which otherwise works: the relay-visible traffic really is anonymous, since the p-tag and author are a transport key and the payload is NIP-44. Both now require the client to be paired. The authorizer interface gains `isPaired` with NO default, so a future authorizer has to state its own rule rather than silently inheriting "everyone is paired". `ping` is deliberately left open. It reveals nothing the caller does not already have — a signer is alive at a pubkey they hold — and first-party behaviour could be confirmed but third-party clients that ping before connecting could not be ruled out. Breaking a legitimate handshake to close a minor oracle is a bad trade. The choice is pinned by a test that also asserts the pairing check is never consulted, so it stays deliberate rather than drifting back by accident. **Decrypt consent showed nothing at all.** The bridge populated the content preview and raw data only for signing requests, so a decrypt request produced an empty preview block — no ciphertext, no counterparty, not even the "Show event" toggle — leaving "AppName wants to read your private messages" with *Allow always* as the primary button. Meanwhile the coordinator documented the opposite: "Amethyst decrypts first, then asks permission to expose." That was never implemented. Now: - The counterparty is resolved and shown, so the prompt reads "…read your private messages **with Alice**". It never degrades to nothing — cached name, else a shortened npub. Knowing *whose* messages is a categorically different decision. - The message is decrypted BEFORE prompting and the plaintext is the preview, as documented. It is a local operation and nothing is exposed until approval. Failure, blank and hang all collapse to an explanatory string under a timeout, so the dialog is never empty and cannot stall. - A narrower grant is offered ALONGSIDE the broad one, not instead of it: `DecryptFrom(counterparty)` keyed `decrypt:` next to `Decrypt`. The dialog's primary button becomes "Always allow for Alice" with the broad option demoted. Because the ledger stores an opaque op key, no persisted decision migrates and the storage format is untouched. Scoping decrypt per counterparty *instead* would have been worse than the bug: a DM client would prompt once per conversation, training users to approve everything. A narrow option beside the broad one gives granularity without the prompt explosion. Also fixes a latent bug found on the way: `AllowForSession` recorded the *requested* op rather than the *granted* one, which would have widened a narrow session grant back to broad. Verified by three sabotage passes; the tests that stayed green under them are the intended negative guards. One existing test asserted the buggy behaviour outright ("public reads are never gated") and was rewritten. Not done: the batched consent sheet still records the broad op for "remember" — offering the narrow choice per row there is a UX design question, not a mechanical change. Needs a device check before release: the decrypt preview runs the account signer before consent. That is free for a local key, but an account backed by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of Amethyst's. Co-Authored-By: Claude Opus 4.8 --- .../consent/SignerConsentActivity.kt | 41 +++- .../consent/SignerConsentCoordinator.kt | 17 ++ .../model/nip46Signer/Nip46ConsentBridge.kt | 80 +++++--- .../nip46Signer/Nip46ConsentInfoBuilder.kt | 176 +++++++++++++++++ .../model/nip46Signer/Nip46SignerState.kt | 7 +- .../amethyst/napplet/NostrSignerOpLabels.kt | 19 ++ .../napplets/ConnectedAppDetailScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 8 + .../Nip46ConsentInfoBuilderTest.kt | 177 ++++++++++++++++++ .../amethyst/cli/commands/BunkerCommand.kt | 15 ++ .../nip46/Nip46PermissionAuthorizer.kt | 56 +++++- .../connectedApps/signers/NostrSignerOp.kt | 18 +- .../signers/NostrSignerPermissionLedger.kt | 4 +- .../nip46/Nip46PermissionAuthorizerTest.kt | 150 +++++++++++++++ .../NostrSignerPermissionLedgerTest.kt | 28 +++ .../server/BunkerRequestProcessor.kt | 58 +++++- .../server/Nip46RequestAuthorizer.kt | 20 +- .../BunkerRequestProcessorConcurrencyTest.kt | 2 + .../server/BunkerRequestProcessorTest.kt | 72 ++++++- .../server/NostrConnectSignerServiceTest.kt | 2 + 20 files changed, 893 insertions(+), 59 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilder.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentInfoBuilderTest.kt 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/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/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index b7fab80348..3472835691 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -30,10 +30,13 @@ 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]. */ @@ -42,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, 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 d7abf620c2..ea09d12af3 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,7 @@ 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.counterpartyLabel import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes import com.vitorpamplona.amethyst.napplet.resolveNappletMeta @@ -703,6 +704,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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e74fab5127..ae9eb3ad26 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -947,6 +947,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 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/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/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/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 9688631c8b..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 @@ -168,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 { 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 c6a0ed1fc0..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 @@ -106,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/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/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, From c6a6068486475a7bf4b249b93cd0a3973b9a9cd5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 22:01:19 -0400 Subject: [PATCH 23/43] fix(chats): stop showing ciphertext in DM previews; fix stuck npub names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs found by a device sweep of the Messages list. **Raw NIP-04 ciphertext rendered as the message preview.** Rows in New Requests showed base64 blobs like `0tyoSVovKSK9uDKLUVMs137TD0b+vz…`. Cause: every decryption branch in `Account.cachedDecryptContent` and `decryptContent` is gated on `isWriteable()`, and the non-writeable path fell through to `event.content` verbatim — which for a kind:4 IS the NIP-04 blob. A read-only (npub-only) login therefore hit this on every legacy DM room. Both functions now return null instead of the ciphertext, which closes the leak on every surface reading them — including the open chatroom body, which had the identical fallthrough. A new pure classifier backs the preview and never reads `event.content` for an encrypted kind, so a future raw fallback cannot resurface there. Pending and undecryptable are now distinguished on facts the UI actually has, rather than collapsed into one message: no key at all, or a kind:4 between two other people, is "could not decrypt"; encrypted with our key a party but plaintext not yet back is "Decrypting…", which resolves itself when the signer answers. The old code showed the not-found string for the pending case. **Group DM titles stuck on npubs while the facepile beside them showed real names** — and this one is not a display bug at all. Both already observe metadata through the same flow; the fault is in `User`: fun metadata() = metadata ?: UserMetadataCache().also { metadata = it } Non-atomic lazy init on a plain field, called from BOTH the Compose main thread (every `observeUserInfo` composition) and the relay/IO threads (`updateUserInfo`). Two threads can each read null, each allocate, and one instance is orphaned. A composable collecting the ORPHANED cache never receives the metadata, so it sits on its pubkey fallback forever while a sibling that got the surviving instance renders the name — which is exactly "npubs in the title, names in the facepile, same row", and why it never recovers. All six per-user lazy caches are now `@Volatile` with double-checked locking under one process-wide lock, held only for the allocation. The store holds tens of thousands of users, so a lock per user would be worse than the bug. This likely explains a broader class of "some names resolve and others never do" symptoms, not just the row that surfaced it. Not fixed: for a read-only account the open chatroom body now renders nothing for a kind:4 rather than ciphertext — better, but it deserves the same "could not decrypt" placeholder the preview row got. Unverified: that the account which showed the ciphertext was in fact read-only. Every other route to ciphertext was traced and returns null, so the non-writeable fallthrough is the only reachable source, but the device state itself was not captured. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 11 +- .../location/GeohashLocationPickerDialog.kt | 66 ++++++--- .../creators/location/LocationPickerMap.kt | 11 +- .../chats/rooms/ChatroomHeaderCompose.kt | 69 ++++++--- amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/model/User.kt | 64 ++++++-- .../commons/model/privateChats/ChatPreview.kt | 105 +++++++++++++ .../model/privateChats/ChatPreviewTest.kt | 140 ++++++++++++++++++ 8 files changed, 406 insertions(+), 62 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt 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 4c131fb678..087c33a7e9 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 @@ -5048,7 +5049,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 } } @@ -5075,6 +5079,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 } 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/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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ae9eb3ad26..4ce37f36be 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 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/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/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), + ) + } +} From f2e42105f7168d83c2d4823a81f169cfb4d58112 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 22:07:37 -0400 Subject: [PATCH 24/43] fix(podcast): stop rendering an episode description twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The episode renderer shows two blocks: the `description` tag via `episodeDescription()`, and the event content as markdown. Most feeds put the SAME text in both, and the thread view renders both (`makeItShort` is false there), so the whole description appeared twice inside one card — one author header, one player, two identical bodies, each with its own "Show More" pill. The existing guard only suppressed the markdown block when the content was blank. It now also suppresses it when the content merely repeats the description, compared on collapsed whitespace so a copy differing only in wrapping still counts as a duplicate. Feeds were unaffected because they pass `makeItShort = true` and never rendered the second block, which is why this only showed on the episode screen. Found by a device sweep of the untested feature areas. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/ui/note/types/PodcastEpisode.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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+") From 717461f4d08f93826ff162a9b8e0bb072906cad2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 22:14:00 -0400 Subject: [PATCH 25/43] fix(chats): draw day headers above the message they introduce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Day separators in group chat did not match the messages under them: a "Jul 1, 2025" header sat directly above a Sep 23 bubble, and a "Sep 23" header above a Sep 25 one — while LATER separators in the same conversation were correct. The obvious explanation — that a bubble group spanning midnight takes its header from the group's first message — is wrong. `ChatGroupPosition` already refuses to group two messages whose formatted dates differ, so a group cannot cross a day boundary at all. The real cause is an off-by-one from a false assumption about `reverseLayout`, stated in the comment that used to sit here: that later content "draws just above the message". `reverseLayout` reverses the order of the LIST'S ITEMS, not the content inside one item — each item's Column still lays out top to bottom. Verified with a throwaway headless Compose layout probe rather than by argument: with `reverseLayout = true` and two items each holding a message and a divisor, item 1 (older) sits above item 0 (newer), but within each item the divisor sits BELOW its own message. So the divisor for item i was labelled with date(i) and gated on date(i+1) != date(i) — the arithmetic was right — but drawn between item i and item i-1, heading the NEXT, newer message while showing the previous one's date. That also explains the detail the grouping theory could not: whenever two consecutive messages fall on the same day the mislabelled header happens to read correctly, which is why only some separators looked wrong. Moving the divisor to the top of the item Column fixes it. Its arguments were already correct, so nothing else changed — no formatting change, no grouping change, and one fewer list lookup since it now reuses the already-computed older note. Subject headers had the same inversion and are fixed with it. One call site serves every chat surface — NIP-29 relay groups, NIP-28 public chats, ephemeral chats, live-activity chats, Concord channels, private DMs, geohash chats and Marmot groups — so all were wrong and all are fixed. `markersInGap` immediately below had the identical bug from the identical assumption: it is handed the bounds for the gap toward the OLDER message but, composed last, rendered in the gap toward the newer one. Moved for the same reason. Not unit-tested, deliberately: the date arithmetic was always correct, so a pure-function test passes both before and after. Catching this needs a Compose layout assertion, and `:amethyst` has no Robolectric or JVM Compose harness — only instrumented tests. The layout probe above is what established the behaviour; it was deleted afterwards. Not verified on-device: the visual result is inferred from that probe plus the reported symptoms. Co-Authored-By: Claude Opus 4.8 --- .../location/GeohashLocationPickerDialog.kt | 68 +++++++------------ .../creators/location/LocationPickerMap.kt | 11 +-- .../loggedIn/chats/feed/ChatFeedView.kt | 31 +++++---- 3 files changed, 44 insertions(+), 66 deletions(-) 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 30dd9450fd..3a103691d8 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,6 +100,7 @@ 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 @@ -124,16 +125,15 @@ private fun zoomForGeohashLength(length: Int): Double = else -> 17.5 } -/** - * 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. - */ +/** 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 = -30.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. + */ +private const val SELECT_MOVE_EPS = 0.0005 /** 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,21 +201,15 @@ 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. + // 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). 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) } @@ -343,8 +337,8 @@ fun GeohashLocationPickerContent( Column(modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth().weight(1f)) { LocationPickerMap( - latitude = initialLat, - longitude = initialLon, + latitude = seed?.centerLat ?: 20.0, + longitude = seed?.centerLon ?: 0.0, pickedLatitude = null, pickedLongitude = null, zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM, @@ -353,12 +347,11 @@ fun GeohashLocationPickerContent( zoomTo = zoomTo, highlight = highlight, highlightColor = highlightColor, - onUserInteraction = { mapTouched = true }, onCenterChanged = { lat, lon -> - // Only a movement the user drove counts as their first real pick. - if (mapTouched) { - pickedLat = lat - pickedLon = 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)) { hasSelection = true } }, @@ -694,24 +687,13 @@ private fun PickerBottomBar( } } Column(Modifier.weight(1f).padding(start = 12.dp)) { - // 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, - ) - } - } + LoadCityName(geohashStr = settledCell ?: cell) { cityName -> + 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 77de813f92..f5e3375398 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,10 +65,6 @@ 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( @@ -84,14 +80,12 @@ 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 @@ -123,10 +117,7 @@ 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) - currentOnUserInteraction?.invoke() - } + MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false) } false 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, - ) } } } From 894dc7d99ba1261240ab57f75f980ed76ff3da06 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 22:22:24 -0400 Subject: [PATCH 26/43] fix(location): don't self-select a location the user never picked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map picker opened blank, showed "Tessalit, ML" — the middle of the Sahara — as if it were chosen, and had Confirm/Teleport ENABLED, so a user could join a geohash cell they never selected. Three of those are one bug and the fourth follows from it. Notably there is no tile, network or Tor problem: tiles were downloading and rendering the whole time. **The defect.** `onCenterChanged` treated any map movement beyond `SELECT_MOVE_EPS` (0.0005°) as the user's first pick. osmdroid emits a scroll when the MapView is first laid out, reporting a pixel-quantized centre — and at the opening zoom one pixel is about 0.4°, roughly 800x the epsilon. The phantom scroll therefore always cleared the threshold and the picker selected a location before the user touched anything. No epsilon can fix that: the quantization error is unbounded at low zoom. Replaced with a `mapTouched` gate, so only finger-driven movement can become a selection. **Why it looked blank.** That phantom selection made the level effect zoom to 12 over empty desert, and the night tile filter darkened featureless Sahara tiles to near-black. Tiles were present; there was nothing to see. Tapping "Region" re-zoomed and a detailed map appeared instantly, which is what ruled out a loading failure. **Why Tessalit.** The self-selection encoded the default centre, and `WORLD_CENTER_LAT/LON = 20.0/0.0` is inland Mali — not "mid-Atlantic" as the comment claimed. Moved to -30.0 longitude, genuinely unnamed ocean and off the prime meridian; two nearby hardcoded 20.0/0.0 literals now use the constants. **The label mismatch was mostly a misread, with a real bug behind it.** 20N/0E sits exactly on the prime meridian, so a sub-kilometre pan flips the geohash between the `e…` and `s…` halves of the world — both cells really were Tessalit, so the name was right. The genuine bug is narrower: the name came from a 450 ms-debounced cell, so mid-pan the previous cell's name rendered beside the new geohash. The name now renders only when the debounced cell matches the live one, and an unresolvable cell shows no label rather than echoing its geohash. Side benefit: the seeded composer path no longer has its exact seed coordinates overwritten by osmdroid's quantized initial scroll. Verified on device before and after, on both the Teleport screen and the composer dialog: tiles on open, no bogus place, no selection until a real touch, Confirm disabled until then; then pan → cell with no label at sea, pan → "Oriximiná, BR", search → "Lisboa, PT", composer pick → confirmed. Pre-existing, not fixed: in the modal dialog only, the picker header is overdrawn by the MapView and invisible. Proven to predate this change by A/B against an unmodified build. Back-dismiss works; cosmetic. Co-Authored-By: Claude Opus 4.8 --- .../location/GeohashLocationPickerDialog.kt | 66 ++++++++++++------- .../creators/location/LocationPickerMap.kt | 11 +++- 2 files changed, 52 insertions(+), 25 deletions(-) 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 From a87049e4518dfd7ae27eaad61870c071fc4f897a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 22:43:40 -0400 Subject: [PATCH 27/43] fix(chats): stop crashing when a relay's group directory is sorted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsing a relay's groups (Relay Groups → Find groups → pick a relay) crashed the app to the launcher: java.lang.IllegalArgumentException: Comparison method violates its general contract! at java.util.TimSort.mergeLo ... at RelayGroupChannelListScreen$allChannels$3$1$1.emit `sortedBy { it.toBestDisplayName().lowercase() }` re-evaluates its key on every comparison, and the key comes from mutable shared state — the channel's display name, which a kind-39000 directory event can change while the sort is running. TimSort detects the inconsistency and throws. The failure scales with directory size and needs no user action: a relay hosting 1237 groups tripped it on the first browse, while a relay with 18 never did — which is why an earlier sweep of this same screen missed it. v1.12.6 already fixed this class elsewhere ("snapshot live-stream status order before sorting"); these sites were not covered. Adds `sortedBySnapshot`, which computes each key ONCE before any comparison runs, and applies it to all four sites that sorted live objects by their display name: the relay group directory (both the initial value and the observer), the parent-group picker, and the name-ordered search results. The picker's site also had to materialise its Sequence first, since sorting lazily would have re-introduced the same window. Verified on device: the exact tap that crashed now loads the screen with zero FATAL EXCEPTIONs and the app stays in the foreground. Found while setting up a NIP-29 group to test the admin surface, which is also how the directory got large enough to expose it. Co-Authored-By: Claude Opus 4.8 --- .../relayGroup/RelayGroupChannelListScreen.kt | 5 ++- .../relayGroup/RelayGroupParentPicker.kt | 3 +- .../commons/search/SearchResultSorter.kt | 3 +- .../amethyst/commons/util/SortedBySnapshot.kt | 39 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/SortedBySnapshot.kt 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/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 } From ab2f4d2da8e9eb4b69fd8c79cb119d91bc8b10c8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 00:18:13 -0400 Subject: [PATCH 28/43] fix(nip29): split group-state REQs so relays stop rejecting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A joined NIP-29 group showed its raw hex id instead of its name — in the header, the Messages list and the browse directory — offered "Join" to an account the relay already listed as an ADMIN, and hid the entire admin surface. Members, Edit group, Invite people, the Threads "+" FAB, pin/unpin, share and the member count all sit behind the same `isMember()` gate, so none of them were reachable. Posting was blocked too. The cause is not membership modelling, warmup skipping, or the local kind-10009 list. **The relay rejected the subscription outright.** The app asked for all five group-state kinds in one filter, captured on the wire: SEND ["REQ",…,{"kinds":[39000,39001,39002,39003,39005],"#d":[…]}] RECV ["CLOSED",…,"blocked: it's not allowed to mix metadata kinds with others"] The whole REQ is dropped, so zero 39000/39001/39002 reach LocalCache. Membership is derived from those events, so it fell back to NONE and every gated control disappeared. Chat messages arrived on a separate `#h` REQ, which is why the group looked half-alive rather than broken. relay29/khatru29 evaluates that rule PER FILTER, confirmed by probe: 39000-39003 + 39005 in one filter -> CLOSED, 0 events the same kinds as two filters -> 4 events, EOSE 39000-39003 alone -> 4 events So the fix is to split the kinds into two filters in the same REQ — no extra subscription, no new assembler. This repairs every joined group on any relay29-family relay, not just the one that surfaced it: the always-on joined-state subscription was hitting the identical rejection everywhere. Two hypotheses were tested and disproved rather than assumed. The join-tap/warmup theory was wrong — instrumentation showed the filter was built correctly with the right scope and only failed on the wire. The `since`-on-replaceable theory was also wrong here: `since` logged null on every call, since the map is per-subscription and reset on disconnect rather than a persisted floor. Both left unchanged. Regression tests fail on the old single-filter behaviour and pass on the new one, verified by reverting the behaviour while keeping the constants so the tests genuinely run rather than fail to compile. Verified on device: header now reads "Amethyst QA 1.13" with an Admin badge and member count 2, composer enabled, overflow menu showing Members / Edit group / Invite people / Leave — all consistent with the relay's roster. Not fixed here: a stale "Requested" state is client-side only and is never reconciled against an arriving roster. With this fix a genuine pending→member transition now resolves, but a rejected 9021 still shows "Requested" until restart. Separate change. Co-Authored-By: Claude Opus 4.8 --- .../subassemblies/FilterRelayGroupState.kt | 37 ++++------- .../datasource/RelayGroupFilterBuilders.kt | 62 ++++++++++++------- .../FilterRelayGroupStateTest.kt | 58 ++++++++++++----- .../RelayGroupFilterBuildersTest.kt | 40 +++++++++--- 4 files changed, 126 insertions(+), 71 deletions(-) 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/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/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 --- From 5f712b3a8a0a012751fd3727e3029ba187ef4c96 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 08:57:52 -0400 Subject: [PATCH 29/43] feat(concord): let a member leave a community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Account.leaveConcordCommunity` has existed since the feature landed and had ZERO callers anywhere in the repo — so joining a Concord community was one-way. It stayed in the account's kind-13302 list and in Messages permanently, with no affordance on the community screen, the Members screen, or Messages. Found because a test account joined a probe community whose only relay then went away: stuck in the list, channels unrecoverable, nothing to tap. Third capability found this session that is fully implemented and unreachable, after `ConcordInviteBundle.isExpired` (called only from a test, so invite expiry was decorative) and `NappletPermissionLedger.endSession` (never called, so session grants outlived every revoke). Each made a feature look complete to anyone reading the model. Adds "Leave community" to the community screen's top-bar overflow, mirroring how NIP-29 relay groups already place membership-destroying actions, behind a confirmation. It renders whether or not the Control Plane ever folded, which is the case that matters — a dead-relay community never folds. The copy is deliberately narrow about what leaving does: it removes the community from THIS account's list and stops syncing, it does NOT notify the community or remove anyone from a roster, and returning needs a new invite. Owner leaving is allowed, with an extra warning. Blocking it would make the actual stuck case unfixable, since the motivating community was one the account created; and it is the user's own private list to edit. But it is irreversible in a way worth stating: `ownerSalt` lives only in that entry, so discarding it retires the community rather than transferring it. Ownership is read from the stored entry rather than the folded authority, because a dead-relay community has no folded authority. Works offline by construction: the underlying call rewrites the local list (falling back to the on-disk backup when nothing folded) and publishes fire-and-forget to the user's OWN outbox — never the community's relays — so the UI does not wait on a relay that cannot answer. Both paths that could resurrect a left entry were checked: stranded recovery iterates live communities only, and the list import takes the newest 13302, which is ours. Tests cover the real logic behind the button — `unfollow` was previously untested — driven through the offline-backup path with no cached relay event: drops only the named community, preserves other memberships' secrets, empties cleanly on the last one, no publish for a community never joined, and the rewritten list stays self-encrypted. Not verified on device: building an APK would have replaced the build a concurrent Concord authority test was running against. The composable itself has no automated coverage — `amethyst` has no Robolectric. Two follow-ups noted, not fixed: leaving does not unpin a community from the bottom bar, so a pinned one leaves a dead tab; and `grantConcordRole` is another zero-caller capability — the general CORD-04 role-grant path is unreachable, with only the narrower make/remove-admin wired up. Co-Authored-By: Claude Opus 4.8 --- .../ui/screen/loggedIn/AccountViewModel.kt | 9 + .../concord/ConcordChannelListScreen.kt | 114 ++++++++++-- amethyst/src/main/res/values/strings.xml | 4 + .../concord/ConcordChannelListLeaveTest.kt | 164 ++++++++++++++++++ 4 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt 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 fd8e385c2d..1621a794e4 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 @@ -1580,6 +1580,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, 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4ce37f36be..cb13a8fdd5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -333,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… 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()) + } +} From 62440748a759077d19e3a596acc489aec2557867 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 09:06:13 -0400 Subject: [PATCH 30/43] fix(concord): stop a rejected edition orphaning the honest ones after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unauthorized control edition in the middle of an entity's chain permanently froze that entity. Observed on device for a member's GRANT: v0 owner (grant mods) v1 owner (grant admins) <- fold stopped here, forever v2 MIDTIER (escalation, correctly rejected) v3,v4,v5 owner orphaned, unreachable `AuthorityResolver` filtered unauthorized editions out BEFORE calling `EditionFold.foldEntity`, and the walk only advances when the next version cites the current head's hash. Removing v2 severed the chain, so every honest edition above it was lost. Any member could permanently freeze any member's role assignment — including the owner's ability to change it — with a single event, recoverable only by a Refounding. It predates the recent rank gates (verified with a zero-role identity); the gates only widen which editions can poison. Armada does not have this bug, and its approach settles the design. Reading its control-plane fold (read for semantics only — Armada is AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the UNFILTERED set, producing an ordered candidate list — chain-verified head first, then every remaining edition version-descending — and authority is applied AFTERWARDS, per candidate, picking the first admissible one. A rejected edition is skipped during the ascending admissibility walk without truncating it. For the chain above, Armada picks v5. So the fix is not to filter later but to gate later: `EditionFold` gains candidate-based gated folding, and the resolver and community state now gate per candidate instead of pre-filtering the pool. Authority checks themselves are unchanged — only WHEN they run moved. Applied to ROLE, GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map. The writer had to be fixed too, for a sharper reason than expected. With an ungated `headOf`, a rogue banlist edition at the tip is read as current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered into an owner-signed one the moment the owner bans anyone else. Tolerant reading cannot heal that, because the resulting edition is genuinely authorized. `ConcordModeration.headOf` now folds the authority-gated heads, and `owner` is a REQUIRED parameter rather than defaulted, since a silently-wrong default here is a consensus footgun. Banlist healing is preserved with one necessary change: the ancestry walk now runs over the full pool rather than the authorized subset. Ancestry is structural — walking only authorized editions stops at the rejected one and misreads genuine ancestors as concurrent forks, resurrecting bans an unban had cleared. Six regression tests, each verified to fail without the fix. Two process notes worth recording: the first "without the fix" run reported BUILD SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting it would have meant concluding the tests were worthless. And the forged-edition test initially passed both ways because the forgery's content coincided with the honest outcome; it was rewritten so the mid-chain arm genuinely discriminates. The rank-gate, rogue-higher-version, floor and rollback tests all pass unchanged. Known gap: `headOf` gates through the per-kind permission map, which is coarser than the resolver's rank gates, so the writer can still pick a head the reader rejects when an in-permission but out-of-rank edition sits at the tip. Tolerant reading makes that benign, but it is not an exact reader/writer match; tightening it needs the resolver to expose per-entity heads. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 22 ++-- .../cli/commands/ConcordModCommands.kt | 10 +- .../commons/actions/ConcordModeration.kt | 64 +++++---- .../commons/actions/ConcordModerationTest.kt | 64 +++++++-- .../model/concord/ConcordRollbackFloorTest.kt | 3 + .../cord02Community/ConcordCommunityState.kt | 32 +++-- .../concord/cord04Roles/AuthorityResolver.kt | 112 ++++++++-------- .../quartz/concord/cord04Roles/EditionFold.kt | 81 ++++++++++++ .../ConcordCommunityStateTest.kt | 35 +++++ .../cord04Roles/AuthorityResolverTest.kt | 123 ++++++++++++++++++ 10 files changed, 429 insertions(+), 117 deletions(-) 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 087c33a7e9..76f667ebdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2368,7 +2368,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 } @@ -2430,12 +2430,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 } @@ -2447,7 +2447,7 @@ 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 } @@ -2482,7 +2482,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 } @@ -2494,7 +2494,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 } @@ -2533,7 +2533,7 @@ 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) } @@ -2752,7 +2752,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 } @@ -2770,7 +2770,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 } @@ -2792,7 +2792,7 @@ class Account( ?.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()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -2813,7 +2813,7 @@ class Account( ?.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()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } 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..22f25b25e9 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,7 @@ 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(), + "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray(), sc.owner).toList(), ), ) return 0 @@ -84,7 +84,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 +108,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 +146,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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index ef0210be9c..cd879c549d 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,13 +20,13 @@ */ 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.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind -import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -36,6 +36,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 @@ -57,30 +58,39 @@ import kotlinx.serialization.builtins.serializer */ object ConcordModeration { /** - * The current head of ([kind], [entityId]) within [current], or null if the entity - * has no editions yet. + * 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]. * - * [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 would fork the chain at an - * already-used version, and [EditionFold] would then break the tie by - * `minByOrNull { rumorId }` — a coin flip that can silently drop the new edition - * (an unban or a role revocation quietly failing to apply). Fold the entity's - * chain instead, exactly as every reader does. + * 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, - kind: ControlEntityKind, entityId: ByteArray, - ): ControlEdition? = EditionFold.foldEntity(current.filter { it.entityKind == kind && it.entityId.contentEquals(entityId) }) + owner: HexKey, + ): ControlEdition? = ConcordCommunityState.authorizedHeads(current, owner)[entityId.toHexKey()]?.known - /** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */ + /** 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 = headOf(current, kind, entityId) + val head = headOf(current, entityId, owner) return if (head != null) (head.version + 1) to head.hash else 0L to null } @@ -111,8 +121,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) } @@ -132,8 +143,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) } @@ -152,8 +164,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) } @@ -168,9 +181,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) } @@ -184,7 +198,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( @@ -195,15 +210,17 @@ 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). */ fun currentBanned( current: List, communityId: ByteArray, + owner: HexKey, ): Set { val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) - val head = headOf(current, ControlEntityKind.BANLIST, entityId) + val head = headOf(current, entityId, owner) return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet() } @@ -215,9 +232,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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index 05e6c5ad15..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 @@ -65,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) @@ -83,18 +83,18 @@ 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)) @@ -123,8 +123,8 @@ class ConcordModerationTest { 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)), cp) - editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L)), cp) + 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) @@ -137,7 +137,7 @@ class ConcordModerationTest { // 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) + 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. @@ -163,15 +163,55 @@ class ConcordModerationTest { 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)), cp) - editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L)), cp) + 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)), cp).single() + 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/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt index f571432ccc..978b17130f 100644 --- 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 @@ -65,6 +65,7 @@ class ConcordRollbackFloorTest { metadata = MetadataEntity(name = "Nostrichs HQ"), current = genesisEditions, createdAt = 2L, + owner = community.ownerPubKey, ) val epoch0Wraps = community.genesisWraps + rename @@ -125,6 +126,7 @@ class ConcordRollbackFloorTest { metadata = MetadataEntity(name = "Nostrichs HQ"), current = genesisEditions, createdAt = 2L, + owner = community.ownerPubKey, ) val epoch0Wraps = community.genesisWraps + rename @@ -219,6 +221,7 @@ class ConcordRollbackFloorTest { metadata = MetadataEntity(name = "Hijacked"), current = genesisEditions, createdAt = 2L, + owner = community.ownerPubKey, ) val floors = 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 98a54f0150..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 @@ -94,11 +94,13 @@ class ConcordCommunityState( val out = HashMap(floors) for ((kind, list) in pool.groupBy { it.entityKind }) { val bit = requiredPermission(kind) - val gated = - list.filter { + // 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 EditionFold.fold(gated, floors)) { + 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] @@ -132,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), floors) + 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), floors).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 3c409f4127..f9ccfe1e74 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 @@ -168,61 +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 { e -> - val author = e.author.lowercase() - if (author == ownerLower) return@filter true - if (!holdsManageRoles(author)) return@filter false - val authorRank = rankOf(author) ?: return@filter false - val r = ConcordJson.decodeOrNull(e.content) ?: return@filter 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@filter false - if (!r.deleted && r.position <= authorRank) return@filter false - // Nor may it grant bits the author does not itself hold, which would - // otherwise escalate through a role rather than through a grant. - r.deleted || bitsOf(author).hasAll(r.permissionBits()) - }, - ) ?: 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... - if (!g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return@filter 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()) - targetRank == null || granterRank < targetRank - }, - ) ?: 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() } @@ -250,16 +252,22 @@ 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) 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) + // 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()) } 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 dc2a898e9f..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 @@ -188,6 +188,87 @@ 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 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 1c6034d1df..d5718451e2 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 @@ -382,4 +382,127 @@ 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)) + } } From b0668baeec3184c1878fd0225c7e7a2304e0978a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 09:13:37 -0400 Subject: [PATCH 31/43] fix(napplet): make revoking an app actually drop its live grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, each of which made revocation look like it worked. **`revokeSessionGrants` had no callers.** It was added with the KDoc "so revoking an app takes effect immediately instead of lingering until this broker instance dies" and then never wired, so revoking an app in Connected Apps left its in-memory session grants active. The user revokes; the app keeps signing. **And it was broken as written.** `sessionAllows` keys are the account-namespaced `napplet::|`, but every revoke call site holds the BARE coordinate, so the prefix match found nothing. Wiring it naively would have looked correct and silently done nothing. It now namespaces before matching, and also clears the post-Cancel re-prompt cooldown so a revoked app prompts on next use instead of being quietly dropped. **Worse: there were three ledgers.** `NappletBrokerService`, `ConnectedAppsScreen` and `ConnectedAppDetailScreen` each constructed their own `NappletPermissionLedger`, while ALLOW_SESSION grants are per-instance in-memory state. So "Forget" cleared the screen's own always-empty session map while the grants the broker actually consults lived on. The KDoc described a process-wide singleton; it wasn't one. Promoted to a real singleton in AppModules alongside the existing permission store, and shared by all three. The screens are plain composables with no binder to the broker service, so rather than invent an IPC path the cached broker moved to the service's companion under a lock — matching the sibling main-process registries in that package. Both revoke paths call it: the Forget button and the per-op revoke. Also gives `NappletPermissionLedger.endSession()` its first caller, which promoting the ledger made necessary: it used to die with the service, so session grants had a natural bound. Now that it outlives the service, `onDestroy` restores exactly the lifetime ALLOW_SESSION already implied. The boundary is safe — the service is bind-only and is destroyed only once every applet and browser surface has unbound, so switching between two open applets never drops grants mid-use. Deliberately NOT wired to account switch (already handled by account-keying) or to backgrounding (would re-prompt mid-use). Test notes, kept honest: the revoke test was verified to fail before the namespacing fix. The `endSession` test PASSES without the change — `endSession` itself was always correct, the bug was that nobody called it — so it is characterization for the new lifetime contract, not a regression test. The `onDestroy` wiring and the composable click handlers have no automated coverage; `amethyst` has no Robolectric and no harness was invented for them. Known gap, left alone deliberately: changing an app's trust level to PARANOID does not drop its live session grants, because `sessionAllows` is consulted before the signer ledger. That is a revoke-shaped action and belongs in the same fix, but it is a behaviour change and was out of scope tonight. Co-Authored-By: Claude Opus 4.8 --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 +++ .../amethyst/napplet/NappletBrokerService.kt | 80 +++++++++++----- .../napplets/ConnectedAppDetailScreen.kt | 17 +++- .../loggedIn/napplets/ConnectedAppsScreen.kt | 2 +- .../amethyst/commons/napplet/NappletBroker.kt | 17 +++- .../commons/napplet/NappletBrokerTest.kt | 93 +++++++++++++++++++ .../NappletPermissionLedgerTest.kt | 20 ++++ 7 files changed, 216 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index b90881636a..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 @@ -704,6 +705,18 @@ class AppModules( // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. 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 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 7b37c290b4..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 @@ -78,8 +77,10 @@ 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, Amethyst.instance.nappletAccountScope) } + // 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. @@ -90,9 +91,6 @@ class NappletBrokerService : Service() { 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. 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() @@ -120,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() } @@ -359,23 +364,24 @@ class NappletBrokerService : Service() { * 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 brokerFor(accountPubKey: HexKey): NappletBroker? { val account = accountFor(accountPubKey) ?: 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 + 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 + } } /** @@ -430,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/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index ea09d12af3..b60173b5f5 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,7 @@ 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 @@ -118,7 +119,7 @@ fun ConnectedAppDetailScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore, Amethyst.instance.nappletAccountScope) } + val capabilityLedger = Amethyst.instance.nappletPermissionLedger val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } val untitled = stringResource(CommonsR.string.napplet_untitled) @@ -239,7 +240,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) + } + }, ) } } @@ -295,6 +303,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() }, 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 caa36cc5ae..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, Amethyst.instance.nappletAccountScope) } + val capabilityLedger = Amethyst.instance.nappletPermissionLedger val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } var items by remember { mutableStateOf?>(null) } 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 43bb40820d..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 @@ -470,7 +470,10 @@ class NappletBroker( * 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 = "napplet:${signer.pubKey}:${identity.coordinate}" + 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 @@ -485,10 +488,20 @@ class NappletBroker( /** * 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 { - sessionAllows.removeAll { it.startsWith("$coordinate|") } + val prefix = "${signerCoordinateFor(coordinate)}|" + sessionAllows.removeAll { it.startsWith(prefix) } + cancelledUntil.remove(coordinate) } } 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 35bfd92705..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 @@ -308,6 +311,96 @@ class NappletBrokerTest { } } + /** 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 { 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 abf7df7c20..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 @@ -52,6 +52,26 @@ class NappletPermissionLedgerTest { 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 { From 08bd5d9b4a33b43037703fb90a90a521f78d5ff6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 09:28:34 -0400 Subject: [PATCH 32/43] fix(napplet): tightening an app's trust level drops its live grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing an app to PARANOID did not stop it signing. `sessionAllows` is consulted BEFORE `signerLedger.decide()`, so a live "allow for this session" grant short-circuits exactly the check the stricter policy would have failed. The user picks "I'm a bit paranoid", the UI updates, and the app carries on signing on the strength of a grant made under the old policy — until every applet surface closes. The trust level is a decision about how an app is treated from now on, so the policy change now drops what that app is currently holding, the same way revoking and forgetting already do. It fires on any change rather than only on tightening: loosening is the user's call too, and a stale grant surviving a deliberate re-decision is surprising in either direction. Completes the revocation work — the three paths that change what an app may do (forget, per-op revoke, trust level) now all clear its live session grants. Not automatically tested: this is a Compose click handler and `amethyst` has no Robolectric. The underlying `revokeSessionGrants` is covered by `revokingAnAppDropsItsLiveSessionSignerGrants`, which was verified to fail before its namespacing fix. Co-Authored-By: Claude Opus 4.8 --- .../loggedIn/napplets/ConnectedAppDetailScreen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 b60173b5f5..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 @@ -221,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) + } }, ) } From 4255ba334584d14decbb0d05d0e8eb742b5d46f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 10:09:24 -0400 Subject: [PATCH 33/43] fix(browser): capture icons from sites that declare only an SVG favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pinned web app showed a generic placeholder instead of its icon — ditto.pub failed while brainstorm.nosfabrica.com worked. The discriminator is what each site declares: brainstorm: favicon.svg AND favicon.png <- the PNG is why it works ditto: logo.svg only (+ an apple-touch-icon nobody read) Icons were captured solely through `WebChromeClient.onReceivedIcon`, which hands back a rasterized Bitmap. Android WebView does not decode SVG favicons into that callback, so a site offering a raster alternative gets picked up and an SVG-only site never fires it at all: nothing is recorded, `iconModelFor()` returns null forever, and it fails silently — no error, no log line, just a permanently generic icon. SVG-only declarations are increasingly the norm, so this was set to affect more apps over time. Adds a sniffer that, after a main-frame load, ranks the page's declared icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon → /favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through on failure. The bytes are size-bounded and magic-byte validated before being relayed down the existing record path; anything unrecognised is dropped. Fetching in page context is the point, not an implementation detail. The registry captures from the WebView deliberately — "an alternative to the main app fetching host/favicon.ico itself, which would bypass Tor and leak the request". Ditto's /favicon.ico exists and returns a valid icon, so fetching it from the app would have been the easy fix and the wrong one. Every byte still comes through the sandbox WebView's own network path. No rasterisation needed: coil3's SVG decoder is registered on the singleton loader and sniffs by content rather than extension, so stored SVG bytes decode even under the registry's .png filename. Only a leading BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants. Also fixes a second gap found while diagnosing: `NappletBrowserService` — the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at all, so a pinned app's icon depended on having once opened it full screen. It now gets both the missing raster callback and the sniff. `NappletHostService` deliberately left alone: it serves verified blobs over a synthetic internal host, and applet icons already come from the manifest, so capture there would be dead code. The sniff runs after a delay and skips when the raster callback already claimed the host, so sites that worked before are untouched. Verified on device: ditto.pub now shows its real icon on the favorite card, the pinned sidebar tab and the recents row, via both the embedded and full-screen paths; Brainstorm is unchanged; example.com (no usable icon) degrades to the placeholder with zero record calls and no hang; no crashes. Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on the IPC handler thread, tripping StrictMode. It did so before this change — which now simply gives it more occasions. Moving that write off-main is a cheap follow-up. Co-Authored-By: Claude Opus 4.8 --- .../napplethost/NappletBrowserActivity.kt | 33 +++ .../napplethost/NappletBrowserService.kt | 85 +++++++ .../napplethost/NappletFaviconSniffer.kt | 233 ++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFaviconSniffer.kt 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 ff389271d7..8d5cb59f9d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -432,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, @@ -470,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 = @@ -754,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" 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 447499bbd8..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 @@ -98,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() @@ -328,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( @@ -385,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) } @@ -401,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( @@ -551,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) @@ -603,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() +} From a040aa522c31f5563e439d4030643b7118f4d758 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 10:52:05 -0400 Subject: [PATCH 34/43] fix(perf): keep favicon and settings disk reads off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two StrictMode disk violations on startup and on IPC, fixed differently because they are different problems. **Favicon storage was writing on the IPC handler thread**, i.e. the main looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and `init` scanned the directory with `listFiles`. Both now run on an IO scope. The directory is still published synchronously so `iconModelFor` and `record` work immediately — only the scan and the write are deferred. `keys` updates after the bytes are actually on disk, so a reader is never told an icon exists before the file backing it does, and until the scan lands an icon renders its placeholder for a frame and then recomposes, which is what the StateFlow is for. **The settings read was left synchronous on purpose, and stays that way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy initialiser; the surrounding comments record why it is not hydrated asynchronously — an async hydrate reopens a window where a late disk read clobbers a user's toggle. Converting it would have looked like a StrictMode cleanup while resurrecting a settings-corruption bug. Instead the prefs file is warmed on an IO thread at startup, so the first synchronous read hits SharedPreferences' in-memory cache. Best-effort by design: if a main-thread reader wins the race it pays the disk hit once, exactly as before, and correctness is unchanged either way. Verified on device: `notificationServiceEnabled` StrictMode hits went from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start, no crashes — and both pinned web-app icons (ditto.pub and Brainstorm) still render, which is the thing making the write async could plausibly have broken. Co-Authored-By: Claude Opus 4.8 --- .../com/vitorpamplona/amethyst/Amethyst.kt | 7 ++++ .../amethyst/LocalPreferences.kt | 13 ++++++ .../amethyst/favorites/BrowserIconRegistry.kt | 42 +++++++++++++++---- 3 files changed, 54 insertions(+), 8 deletions(-) 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/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/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) + } } } From 4ee0416a9056657fee93ee9b4dfbbcfb5801334c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 10:56:21 -0400 Subject: [PATCH 35/43] docs(qa): record v1.13.0 test coverage, open findings and recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30 fixes from this session each carry their reasoning in their own commit message, and the regression tests are in the repo. What none of that captures is the shape of the testing itself: what was actually exercised, what was not, what we knowingly left broken, and how to reproduce the setups. That lived only in one conversation. Records four things: - **Coverage**, honestly split three ways: exercised on device; fixed but only unit-verified (roughly half the session's work, including the entire Concord authority chain); and never opened at all (Nests, Marmot, the whole Desktop app, payments, notifications). Plus the platform gaps — one tablet, one flavour, one ABI, Android 14 only while targetSdk is 37, and no real `release` build. - **Open findings** we chose not to fix, so they are decisions rather than oversights: the unbumped versionCode, the Firebase scheduling failure that may cost Crashlytics, Tor settings needing a restart, an unreachable relay reported as empty, WebView profiles surviving logout, control-plane edits still dropping unknown keys, and the CORD-05 root-binding gap that Armada shares and therefore needs a spec conversation. - **Setup recipes** that took real time to work out: isolating `amy` by `$HOME`, which relays actually accept a NIP-29 create, deep-linking past a 1000-entry directory, and proving "no network before consent" by running a local relay over `adb reverse` and counting events either side of the tap. - **Patterns** that recurred often enough to be process problems: tests that assert the bug, implemented-but-unreachable capabilities, Gradle serving a stale up-to-date test run and reporting success for a deliberately broken build, and four confident diagnoses that measurement overturned. Written for whoever picks this up next, including the parts that reflect badly on the testing rather than only the parts that reflect well. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-20-v1.13.0-release-qa.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 amethyst/plans/2026-07-20-v1.13.0-release-qa.md 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..9d243c369e --- /dev/null +++ b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md @@ -0,0 +1,154 @@ +# 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 | + +### 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) · notifications / push · 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. +- `grantConcordRole` is implemented and unreachable; the changelog claims role grants ship. A + proportionate UI exists (a picker beside "Make admin"); it was blocked by concurrent work. + +--- + +## 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. + +**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. From 97b861dd5e0b0969dcf710794b7f7f740be67aed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 11:34:07 -0400 Subject: [PATCH 36/43] fix(notifications): remove the 7-day since floor that emptied the tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notifications query pinned `since` to `oneWeekAgo()` whenever it had no EOSE timestamp. Combined with two other facts that produced a deadlock: - the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`), so EVERY cold start re-pinned the window to 7 days; and - the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms once the feed holds a FULL page (`notes.size >= localFilter.limit()`). The feed could not fill a page because the query only asked for a week, and the query could not widen because the feed never filled. Any account whose last inbound mention was older than 7 days saw a near-empty Notifications tab forever — including a fresh install of a long-established account. Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds 1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired to the paging fallback that the secondary per-key kinds received. Home is the precedent and does not do this: `filterHomePostsByAllFollows` passes `since ?: boundary`, i.e. plain null on a cold start, relying on the relay-side `limit` to bound the response. Notifications was the outlier. These filters are `#p`-scoped to the user's own key and carry a `limit` (2000/500/200/20), so an all-time query is one index scan returning at most `limit` events newest-first — same cost, strictly more useful. Measured on the test account before the fix: 163 events p-tagging it exist on relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the account's most recent inbound activity was a month old. After the fix the tab scrolls back through Feb 2025. Co-Authored-By: Claude Opus 4.8 --- ...NotificationsEoseFromInboxRelaysManager.kt | 27 ++++++++++++++++--- ...otificationsEoseFromRandomRelaysManager.kt | 8 +++--- 2 files changed, 28 insertions(+), 7 deletions(-) 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 c8f58630ce..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,17 +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, - since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(), + since = notificationSince, ) } @@ -76,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, ) } 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 9aa0acc87d..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,7 +27,6 @@ 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 @@ -51,8 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // only loads this after the feed is built - val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: 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) From 4e7242a29560e224b24545eef235ffa3ad3ea94c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 11:35:48 -0400 Subject: [PATCH 37/43] feat(concord): assign CORD-04 roles from the Members roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Account.grantConcordRole` had been implemented with zero callers, so role grants were unreachable from the app while the changelog claimed they ship. This adds the missing surface: a "Roles…" item beside "Make admin" opening a multi-select over the roles the viewer may hand out. Both rank rules are enforced by delegating to `AuthorityResolver` rather than reimplementing them: - assignable roles are `roles().filter { myRank < it.position }` — the fold drops a grant whose granter does not strictly outrank every assigned role, so offering one at or above our own position would publish an edition that every client then silently discards; - reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which already folds the whole rule (hold the bit, not banned, target isn't the owner, strictly outrank) and makes self-promotion fall out for free. Out-of-reach members show the item disabled *with a reason* instead of omitting it, so there is no silently no-op control. The grant REPLACES a member's role set rather than merging into it, so the dialog preselects their current roles. That preselection is provably complete: a member's rank is the lowest position they hold, and the dialog only opens when we strictly outrank that rank, so every role they hold sits strictly below us and is therefore rendered — no held role can be silently stripped. `amy concord roles` also gained a `grants:` section reading the post-fixpoint `authority.roleHolders()`. It previously printed role *definitions* but never the *grants*, which made the fold outcome unverifiable from the CLI; a rank-violating grant now shows up as visibly absent rather than as if it landed. Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker hides roles above the viewer, disables on members who outrank them, preselects correctly, and a saved grant survived the fold and a fresh relay drain read back from a second client. Co-Authored-By: Claude Opus 4.8 --- .../ui/screen/loggedIn/AccountViewModel.kt | 18 +++ .../concord/ConcordMembersScreen.kt | 147 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 7 + .../cli/commands/ConcordModCommands.kt | 11 ++ 4 files changed, 180 insertions(+), 3 deletions(-) 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 1621a794e4..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, 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..b443ffd3fa 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,12 @@ fun ConcordMembersScreen( isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true), viewerIsOwner = iAmOwner, viewerCanBan = iCanBan, + 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 +211,9 @@ private fun ConcordMemberRow( isSelf: Boolean, viewerIsOwner: Boolean, viewerCanBan: Boolean, + viewerCanManageRoles: Boolean, + canManageRolesOnTarget: Boolean, + assignableRoles: List, accountViewModel: AccountViewModel, nav: INav, ) { @@ -199,7 +228,31 @@ private fun ConcordMemberRow( val canBan = viewerCanBan && !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 + // 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 +265,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 +294,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 +372,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 +456,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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cb13a8fdd5..f37c43ae9e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -381,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 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 22f25b25e9..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,6 +59,17 @@ object ConcordModCommands { state.roles.map { (id, r) -> mapOf("id" to id, "name" to r.name, "position" to r.position, "permissions" to r.permissions) }, + // 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(), ), ) From c15774e4e161c687d69ee9f738795682f88b5688 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 11:36:39 -0400 Subject: [PATCH 38/43] docs(qa): record the notifications deadlock and close two open findings Adds the Notifications and Concord role-grant rows to the coverage table, retires the "grantConcordRole is unreachable" finding, and records two new ones: the Members roster offering Ban/Remove on members the viewer doesn't outrank (silently dropped on fold), and notification cards whose target note isn't cached rendering as placeholders. Also adds the pattern worth carrying forward: a paging boundary gated on a full page deadlocks against a narrow query window, and the empty state is self-sustaining. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-20-v1.13.0-release-qa.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 index 9d243c369e..30e4dde0f4 100644 --- a/amethyst/plans/2026-07-20-v1.13.0-release-qa.md +++ b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md @@ -27,6 +27,8 @@ Everything below is that one configuration unless stated. | 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 @@ -42,7 +44,7 @@ podcast duplicate description · Concord leave affordance · the three revocatio 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) · notifications / push · search · Calendar, Chess, +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 @@ -87,8 +89,14 @@ pinned messages were never exercised. - **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. -- `grantConcordRole` is implemented and unreachable; the changelog claims role grants ship. A - proportionate UI exists (a picker beside "Make admin"); it was blocked by concurrent work. +- Concord Members roster: `canBan`/`canRemove` check only `viewerCanBan && !isOwnerTarget && + !isSelf` — never rank — while the ban fold enforces `canActOn`. So Ban and Remove are offered on + members who outrank the viewer and are then silently dropped on fold: exactly the no-op-control + trap the new "Roles…" item avoids. Fix is to route both through `canActOn`. Also affects the + message-level ban path, which is why it wasn't folded into the role-grant change. +- 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. --- @@ -135,6 +143,13 @@ while being unreachable to users, and the first one actually invoked turned out 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 From 0ae6bc6698431c1b4bf1f40a333a1e104be1981d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 11:54:28 -0400 Subject: [PATCH 39/43] fix(concord): rank-gate the Ban and Remove affordances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank — unlike the role picker, which routes through `canActOn`. Both the Members roster and the message-level path (`Account.concordBanTarget`, the chokepoint for the quick-action menu, the note dropdown, the note action sections and the chat action sheet) now require `canActOn(me, target, BAN)`. This is NOT the no-op it first looked like. The premise that the fold would drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1 admin is therefore ACCEPTED by every client, and the admin then loses every permission, since `hasPermission` is `!isBanned && ..`. It is privilege escalation, not a silent no-op. The fold is deliberately left alone. Armada has the identical gap — its `banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold would make us ignore bans every other client honors, splitting the banlist across clients. Closing it needs a spec change, like CORD-05. Refusing to AUTHOR such a ban restricts only what we write, never what we accept, so it cannot diverge consensus. Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant and currently fail by design; two companions assert the gate does not over-correct (a moderator still bans a plain member; the owner still bans anyone). Un-ignore the first three when the spec closes the gap. The owner short-circuits the check rather than going through `canActOn`, which begins at `hasPermission` and is false while banned — since a rogue BAN holder *can* currently banlist the owner, routing them through it would let them be locked out of moderating their own community. Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob (Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this member" where Ban and Remove used to be enabled, while the Helper (rank 5) still offers Roles…, Ban and Remove. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-20-v1.13.0-release-qa.md | 17 ++-- .../vitorpamplona/amethyst/model/Account.kt | 23 +++-- .../concord/ConcordMembersScreen.kt | 17 +++- .../cord04Roles/AuthorityResolverTest.kt | 86 +++++++++++++++++++ 4 files changed, 129 insertions(+), 14 deletions(-) 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 index 30e4dde0f4..7c33bb23b2 100644 --- a/amethyst/plans/2026-07-20-v1.13.0-release-qa.md +++ b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md @@ -89,11 +89,18 @@ pinned messages were never exercised. - **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. -- Concord Members roster: `canBan`/`canRemove` check only `viewerCanBan && !isOwnerTarget && - !isSelf` — never rank — while the ban fold enforces `canActOn`. So Ban and Remove are offered on - members who outrank the viewer and are then silently dropped on fold: exactly the no-op-control - trap the new "Roles…" item avoids. Fix is to route both through `canActOn`. Also affects the - message-level ban path, which is why it wasn't folded into the role-grant change. +- **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` — so enforcing rank on the fold unilaterally would make us + ignore bans every other client honors. Needs a spec conversation, like CORD-05. + Amethyst now refuses to *author* such a ban (`Account.concordBanTarget` and the Members roster + both route through `canActOn`), which restricts only what we write, never what we accept. Three + `@Ignore`-d tests in `AuthorityResolverTest` record the fold-level invariant; un-ignore them when + the spec closes 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. 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 76f667ebdc..9ffa1c6f1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2454,10 +2454,19 @@ class Account( /** * 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 @@ -2471,7 +2480,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 } 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 b443ffd3fa..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 @@ -188,6 +188,13 @@ 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 @@ -211,6 +218,7 @@ private fun ConcordMemberRow( isSelf: Boolean, viewerIsOwner: Boolean, viewerCanBan: Boolean, + canBanTarget: Boolean, viewerCanManageRoles: Boolean, canManageRolesOnTarget: Boolean, assignableRoles: List, @@ -222,12 +230,13 @@ 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 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 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 d5718451e2..1890050f03 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 @@ -23,12 +23,18 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +private const val KNOWN_GAP = + "CORD-04 does not rank-gate BANLIST contents and Armada's banlistGate is rank-blind too; " + + "enforcing it here would diverge from every other client. Amethyst refuses to AUTHOR such " + + "a ban instead. Un-ignore when the spec closes this." + class AuthorityResolverTest { private val owner = "0f".repeat(32) private val alice = "a1".repeat(32) @@ -505,4 +511,84 @@ class AuthorityResolverTest { 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 rank-gates ROLE and GRANT editions, but a BANLIST edition is a single whole-list + // entity, so no client rank-checks the *contents* of the list — the gate is the author's BAN + // bit alone. Armada does exactly the same: its `banlistGate` calls the rank-blind + // `isAuthorized(.., Permissions.BAN)`, even though its role path uses the rank-aware + // `canActOnPosition`. Enforcing rank on the fold unilaterally would make us ignore bans every + // other client honors, so the three @Ignore-d tests below record the gap instead of asserting + // a fix. Amethyst restricts what it will *author* (Account.concordBanTarget and the Members + // roster both route through canActOn); closing it for real needs a spec change. + + // A moderator that holds BAN but sits BELOW an admin. The banlist is a single whole-list + // entity, so nothing about the *content* of the list is rank-checked — only the author's bit. + 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 + @Ignore(KNOWN_GAP) + fun aBanHolderCannotBanAMemberItDoesNotOutrank() { + // FAILS TODAY, deliberately unfixed. A rank-5 moderator bans the rank-1 admin above them and + // the fold accepts it — privilege escalation, not a no-op: the admin then loses 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 + @Ignore(KNOWN_GAP) + 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 + @Ignore(KNOWN_GAP) + 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") + } } From ea6762b137099661320746b219f46ff31f8ad30b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 12:05:21 -0400 Subject: [PATCH 40/43] docs(concord): Banlist rank gap is a conformance bug, not a spec gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting the previous commit's reasoning. It concluded from the two implementations that CORD-04 does not rank-gate the Banlist and that enforcing it would be a unilateral divergence. Reading the actual spec (github.com/concord-protocol/concord, not in the Armada repo) shows the opposite: §3 is normative and 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 §5 step 3 restates it. Banning is the example the rule itself picks. Only §4, the section defining the Banlist, states the bit half alone. Both independent implementations read §4 in isolation and made the same mistake, which is evidence about the section rather than about the readers. So the fold fix is spec-mandated. It remains consensus-affecting (we would ignore bans Armada honors until they ship), so it wants coordination rather than a race, and the fold is still unchanged here. Adds docs/concord-banlist-rank-conformance.md to share upstream: verbatim spec citations, both implementations' gates, a delta-based rule that makes the per-target requirement expressible against a whole-list entity, and two further reproduced findings — a banned BAN-holder can lift their own ban (so bans do not stick against any BAN holder), and a forked ban survives an unban that does not chain onto it. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-20-v1.13.0-release-qa.md | 22 +- docs/concord-banlist-rank-conformance.md | 223 ++++++++++++++++++ 2 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 docs/concord-banlist-rank-conformance.md 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 index 7c33bb23b2..7ebfd52193 100644 --- a/amethyst/plans/2026-07-20-v1.13.0-release-qa.md +++ b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md @@ -95,12 +95,24 @@ pinned messages were never exercised. 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` — so enforcing rank on the fold unilaterally would make us - ignore bans every other client honors. Needs a spec conversation, like CORD-05. - Amethyst now refuses to *author* such a ban (`Account.concordBanTarget` and the Members roster - both route through `canActOn`), which restricts only what we write, never what we accept. Three + 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`). + So the fold change is spec-*mandated*, not a unilateral divergence. It is still + consensus-affecting (we would ignore bans Armada honors until they ship), so it wants + coordination rather than a race. Full write-up to share upstream: + `docs/concord-banlist-rank-conformance.md`. + Amethyst currently refuses only to *author* such a ban (`Account.concordBanTarget` and the Members + roster both route through `canActOn`), which restricts what we write, never what we accept. Three `@Ignore`-d tests in `AuthorityResolverTest` record the fold-level invariant; un-ignore them when - the spec closes it. + we enforce. + Also reproduced, and 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), 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. diff --git a/docs/concord-banlist-rank-conformance.md b/docs/concord-banlist-rank-conformance.md new file mode 100644 index 0000000000..a980e4e391 --- /dev/null +++ b/docs/concord-banlist-rank-conformance.md @@ -0,0 +1,223 @@ +# Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance) + +**Status:** conformance bug, reproduced in Amethyst, 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. A note on rollout + +This is a consensus-affecting change: a client that enforces the rank rule will ignore bans that a +client which does not will honor, so the Banlist can diverge between implementations until both +sides ship. We would rather coordinate that than race it. + +Amethyst has therefore **not** changed its fold. We have only stopped our UI from *authoring* a ban +the actor does not outrank — which restricts what we write, never what we accept, and so cannot +diverge anyone's view. We are ready to enforce on the fold as soon as there is agreement on the rule +above and a rough sense of timing. + +## 7. Evidence + +Reproductions live in Amethyst's `AuthorityResolverTest` +(`quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/`), currently `@Ignore`d +with this issue as the reason so they document the invariant without failing the build: + +- `aBanHolderCannotBanAMemberItDoesNotOutrank` +- `aBanHolderCannotBanTheOwner` +- `anUnrankedBanIsDroppedWithoutOrphaningTheRestOfTheList` (asserts the "ignore, don't reject" + semantics proposed above) + +Two companion tests, which pass today, pin the behaviour a fix must preserve: + +- `aBanHolderStillBansThoseItOutranks` +- `theOwnerBansAnyone` + +Happy to port these to Armada's suite or restate them as spec examples if that is useful. From 8913af7a797ed704456026436ccbf382741860b7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 12:53:11 -0400 Subject: [PATCH 41/43] fix(concord)!: enforce CORD-04 rank gating on the Banlist fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the privilege escalation: any BAN holder could ban the authorities above them — including the owner — because the Banlist gate checked only the BAN bit. Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`) and honest clients drop their events, so a single edition from the most junior moderator permanently silenced every admin above them. CORD-04 §3 requires the rank half: "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 §5 step 3. Only §4, which defines the Banlist, states the bit half alone — which is why both this client and Armada shipped the same rank-blind gate. §3 is stated per TARGET while 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, judged against the roster settled behind it; the owner is never a valid target (position 0 is "supreme and unremovable"); and entries the signer may not act on are IGNORED rather than rejecting the edition, so one bad entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a rogue cannot grief the list by forcing rejections. ConcordModeration.currentBanned now reads the honored banlist through the resolver instead of decoding the raw head. Besides picking up the fork healing it was missing, this closes a laundering path: our own next ban/unban would otherwise re-publish an entry our fold refuses, under our signature. BREAKING (consensus): Armada has not shipped this rule, so banlists can differ between clients until it does — we now ignore a ban Armada honors whenever the signer did not outrank the target. Shipping the spec-conformant behaviour was judged better than continuing to honor an escalation. Write-up to send upstream is docs/concord-banlist-rank-conformance.md. The three tests added in 0ae6bc6698 as @Ignore-d documentation now pass and are un-ignored; two companions (a moderator still bans a plain member, the owner still bans anyone) passed throughout and pin what the fix had to preserve. Full :quartz:jvmTest and :commons:jvmTest suites green. Still open and documented, not addressed here: a banned BAN holder can lift their own ban (a fixpoint-ordering question that needs a spec ruling), and a forked ban survives an unban that does not chain onto it. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-20-v1.13.0-release-qa.md | 24 +++---- .../commons/actions/ConcordModeration.kt | 17 +++-- docs/concord-banlist-rank-conformance.md | 34 ++++++---- .../concord/cord04Roles/AuthorityResolver.kt | 65 ++++++++++++++++++- .../cord04Roles/AuthorityResolverTest.kt | 35 ++++------ 5 files changed, 120 insertions(+), 55 deletions(-) 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 index 7ebfd52193..e71cc638ab 100644 --- a/amethyst/plans/2026-07-20-v1.13.0-release-qa.md +++ b/amethyst/plans/2026-07-20-v1.13.0-release-qa.md @@ -102,17 +102,19 @@ pinned messages were never exercised. 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`). - So the fold change is spec-*mandated*, not a unilateral divergence. It is still - consensus-affecting (we would ignore bans Armada honors until they ship), so it wants - coordination rather than a race. Full write-up to share upstream: - `docs/concord-banlist-rank-conformance.md`. - Amethyst currently refuses only to *author* such a ban (`Account.concordBanTarget` and the Members - roster both route through `canActOn`), which restricts what we write, never what we accept. Three - `@Ignore`-d tests in `AuthorityResolverTest` record the fold-level invariant; un-ignore them when - we enforce. - Also reproduced, and 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), and a - forked ban survives an unban that does not chain onto it. + **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. 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 cd879c549d..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 @@ -22,6 +22,7 @@ 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 @@ -213,16 +214,20 @@ object ConcordModeration { 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, owner: HexKey, - ): Set { - val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) - val head = headOf(current, entityId, owner) - return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet() - } + ): Set = AuthorityResolver.resolve(current, owner).bannedMembers() private suspend fun setBanlist( actor: NostrSigner, diff --git a/docs/concord-banlist-rank-conformance.md b/docs/concord-banlist-rank-conformance.md index a980e4e391..afbe98fdbd 100644 --- a/docs/concord-banlist-rank-conformance.md +++ b/docs/concord-banlist-rank-conformance.md @@ -1,6 +1,7 @@ # Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance) -**Status:** conformance bug, reproduced in Amethyst, present by inspection in Armada. +**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. @@ -193,29 +194,34 @@ Separately, please rule on **#3**: whether a banned npub's Banlist edition is ho §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. A note on rollout +## 6. Rollout status -This is a consensus-affecting change: a client that enforces the rank rule will ignore bans that a -client which does not will honor, so the Banlist can diverge between implementations until both -sides ship. We would rather coordinate that than race it. +**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). -Amethyst has therefore **not** changed its fold. We have only stopped our UI from *authoring* a ban -the actor does not outrank — which restricts what we write, never what we accept, and so cannot -diverge anyone's view. We are ready to enforce on the fold as soon as there is agreement on the rule -above and a rough sense of timing. +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/`), currently `@Ignore`d -with this issue as the reason so they document the invariant without failing the build: +(`quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/`). Each failed before +the change and passes after it: - `aBanHolderCannotBanAMemberItDoesNotOutrank` - `aBanHolderCannotBanTheOwner` -- `anUnrankedBanIsDroppedWithoutOrphaningTheRestOfTheList` (asserts the "ignore, don't reject" - semantics proposed above) +- `anUnrankedBanIsDroppedWithoutOrphaningTheRestOfTheList` (pins the "ignore, don't reject" + semantics of §5) -Two companion tests, which pass today, pin the behaviour a fix must preserve: +Two companions pin the behaviour the fix had to preserve, and passed throughout: - `aBanHolderStillBansThoseItOutranks` - `theOwnerBansAnyone` 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 f9ccfe1e74..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 @@ -256,13 +256,74 @@ class AuthorityResolver private constructor( 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() // 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()) } + 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 @@ -270,7 +331,7 @@ class AuthorityResolver private constructor( 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/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index 1890050f03..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 @@ -23,18 +23,12 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -private const val KNOWN_GAP = - "CORD-04 does not rank-gate BANLIST contents and Armada's banlistGate is rank-blind too; " + - "enforcing it here would diverge from every other client. Amethyst refuses to AUTHOR such " + - "a ban instead. Un-ignore when the spec closes this." - class AuthorityResolverTest { private val owner = "0f".repeat(32) private val alice = "a1".repeat(32) @@ -514,17 +508,17 @@ class AuthorityResolverTest { // ---- Rank gating on the banlist (CORD-04 "equal cannot act on equal") ---- // - // CORD-04 rank-gates ROLE and GRANT editions, but a BANLIST edition is a single whole-list - // entity, so no client rank-checks the *contents* of the list — the gate is the author's BAN - // bit alone. Armada does exactly the same: its `banlistGate` calls the rank-blind - // `isAuthorized(.., Permissions.BAN)`, even though its role path uses the rank-aware - // `canActOnPosition`. Enforcing rank on the fold unilaterally would make us ignore bans every - // other client honors, so the three @Ignore-d tests below record the gap instead of asserting - // a fix. Amethyst restricts what it will *author* (Account.concordBanTarget and the Members - // roster both route through canActOn); closing it for real needs a spec change. + // 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. The banlist is a single whole-list - // entity, so nothing about the *content* of the list is rank-checked — only the author's bit. + // 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) = @@ -539,11 +533,10 @@ class AuthorityResolverTest { ) @Test - @Ignore(KNOWN_GAP) fun aBanHolderCannotBanAMemberItDoesNotOutrank() { - // FAILS TODAY, deliberately unfixed. A rank-5 moderator bans the rank-1 admin above them and - // the fold accepts it — privilege escalation, not a no-op: the admin then loses every - // permission, because hasPermission() is `!isBanned && ..`. + // 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") @@ -551,7 +544,6 @@ class AuthorityResolverTest { } @Test - @Ignore(KNOWN_GAP) 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 @@ -581,7 +573,6 @@ class AuthorityResolverTest { } @Test - @Ignore(KNOWN_GAP) 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 From 0214382d0f4967e2b5a5c8b07b0ba9f97d00d8b0 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:19:06 +0000 Subject: [PATCH 42/43] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-ar-rSA/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-bn-rBD/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-cs/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-el-rGR/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-en-rGB/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-eo-rUY/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-es-rES/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-es-rMX/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-es-rUS/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-fa-rIR/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-fi-rFI/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-fr-rCA/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-fr-rFR/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-hi-rIN/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-hu-rHU/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-in-rID/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-it-rIT/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-ja-rJP/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-ko-rKR/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-lv-rLV/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-nl-rNL/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-pl-rPL/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-pt-rPT/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-ru-rRU/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-ru-rUA/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-sl-rSI/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-sr-rSP/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-sw/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-ta-rIN/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-th-rTH/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-tr-rTR/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-uk-rUA/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-uz-rUZ/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-vi-rVN/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-zh-rCN/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-zh-rHK/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-zh-rSG/strings.xml | 15 +++++++++++++++ amethyst/src/main/res/values-zh-rTW/strings.xml | 15 +++++++++++++++ 41 files changed, 615 insertions(+) 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..bf547fdee3 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 @@ -783,6 +784,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 +795,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 +839,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 發票。 + + + + + + + + + From b2cfb5e7eedeca7bd17c6be3efb45e92c2fca499 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:14:54 +0000 Subject: [PATCH 43/43] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-sl-rSI/strings.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index bf547fdee3..f9227271c8 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -313,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 @@ -344,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…